class AppRunner(Runner): """Invoke a Rose application.""" NAME = "app" OPTIONS = [ "app_mode", "command_key", "conf_dir", "defines", "install_only_mode", "new_mode", "no_overwrite_mode", "opt_conf_keys" ] def __init__(self, *args, **kwargs): Runner.__init__(self, *args, **kwargs) path = os.path.dirname( os.path.dirname(sys.modules["metomi.rose"].__file__)) self.builtins_manager = SchemeHandlersManager([path], "rose.apps", ["run"], None, *args, **kwargs) self.date_time_oper = RoseDateTimeOperator() def run_impl(self, opts, args, uuid, work_files): """The actual logic for a run.""" # Preparation. conf_tree = self.config_load(opts) self._prep(conf_tree, opts) self._poll(conf_tree) # Run the application or the command. app_mode = conf_tree.node.get_value(["mode"]) if app_mode is None: app_mode = opts.app_mode if app_mode is None: app_mode = os.getenv("ROSE_APP_MODE") if app_mode in [None, "command"]: return self._command(conf_tree, opts, args) else: builtin_app = self.builtins_manager.get_handler(app_mode) if builtin_app is None: raise UnknownBuiltinAppError(app_mode) return builtin_app.run(self, conf_tree, opts, args, uuid, work_files) def get_command(self, conf_tree, opts, args): """Get command to run.""" command = self.popen.list_to_shell_str(args) if not command: names = [ opts.command_key, os.getenv("ROSE_APP_COMMAND_KEY"), os.getenv("ROSE_TASK_NAME"), "default" ] for name in names: if not name: continue command = conf_tree.node.get_value(["command", name]) if command is not None: break return command def _prep(self, conf_tree, opts): """Prepare to run the application.""" if opts.new_mode: self._prep_new(opts) # Dump the actual configuration as rose-app-run.conf ConfigDumper()(conf_tree.node, "rose-app-run.conf") # Environment variables: PATH self._prep_path(conf_tree) # Free format files not defined in the configuration file file_section_prefix = self.config_pm.get_handler("file").PREFIX for rel_path, conf_dir in conf_tree.files.items(): if not rel_path.startswith("file" + os.sep): continue name = rel_path[len("file" + os.sep):] # No sub-directories, very slow otherwise if os.sep in name: name = name.split(os.sep, 1)[0] target_key = file_section_prefix + name target_node = conf_tree.node.get([target_key]) if target_node is None: conf_tree.node.set([target_key]) target_node = conf_tree.node.get([target_key]) elif target_node.is_ignored(): continue source_node = target_node.get(["source"]) if source_node is None: target_node.set(["source"], os.path.join(conf_dir, "file", name)) elif source_node.is_ignored(): continue # Process Environment Variables self.config_pm(conf_tree, "env") # Process Files self.config_pm(conf_tree, "file", no_overwrite_mode=opts.no_overwrite_mode) def _prep_new(self, opts): """Clear out run directory on a --new option if possible.""" conf_dir = opts.conf_dir if not conf_dir or os.path.abspath(conf_dir) == os.getcwd(): raise NewModeError(os.getcwd()) for path in os.listdir("."): self.fs_util.delete(path) @staticmethod def _prep_path(conf_tree): """Add bin directories to the PATH seen by the app command.""" paths = [] for conf_dir in conf_tree.conf_dirs: conf_bin_dir = os.path.join(conf_dir, "bin") if os.path.isdir(conf_bin_dir): paths.append(conf_bin_dir) if paths: value = os.pathsep.join(paths + [os.getenv("PATH")]) conf_tree.node.set(["env", "PATH"], value) else: conf_tree.node.set(["env", "PATH"], os.getenv("PATH")) def _poll(self, conf_tree): """Run any configured file polling.""" poller = Poller(self.popen, self.handle_event) poller.poll(conf_tree) def _command(self, conf_tree, opts, args): """Run the command.""" command = self.get_command(conf_tree, opts, args) if not command: self.handle_event(CommandNotDefinedEvent()) return if os.access("STDIN", os.F_OK | os.R_OK): command += " <STDIN" self.handle_event("command: %s" % command) if opts.install_only_mode: return self.popen(command, shell=True, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin)
class RosieSvnPreCommitHook(object): """A pre-commit hook on a Rosie Subversion repository. Ensure that commits conform to the rules of Rosie. """ IGNORES = "svnperms.conf" RE_ID_NAMES = [r"[Ra-z]", r"[Oa-z]", r"[S\d]", r"[I\d]", r"[E\d]"] LEN_ID = len(RE_ID_NAMES) ST_ADD = "A" ST_DELETE = "D" ST_UPDATE = "U" TRUNK_INFO_FILE = "trunk/rose-suite.info" TRUNK_KNOWN_KEYS_FILE = "trunk/rosie-keys" def __init__(self, event_handler=None, popen=None): if event_handler is None: event_handler = Reporter() self.event_handler = event_handler if popen is None: popen = RosePopener(self.event_handler) self.popen = popen path = os.path.dirname( os.path.dirname(sys.modules["metomi.rosie"].__file__)) self.usertools_manager = SchemeHandlersManager([path], "rosie.usertools", ["verify_users"]) def _get_access_info(self, info_node): """Return (owner, access_list) from "info_node".""" owner = info_node.get_value(["owner"]) access_list = info_node.get_value(["access-list"], "").split() access_list.sort() return owner, access_list def _get_info(self, repos, path_head, txn=None): """Return a ConfigNode for the "rose-suite.info" of a suite. The suite is located under path_head. """ opt_txn = [] if txn is not None: opt_txn = ["-t", txn] t_handle = tempfile.TemporaryFile() path = path_head + self.TRUNK_INFO_FILE t_handle.write(self._svnlook("cat", repos, path, *opt_txn).encode()) t_handle.seek(0) info_node = ConfigLoader()(t_handle) t_handle.close() return info_node def _svnlook(self, *args): """Return the standard output from "svnlook".""" command = ["svnlook"] + list(args) data = self.popen(*command, stderr=sys.stderr)[0] return data.decode() def _verify_users(self, status, path, txn_owner, txn_access_list, bad_changes): """Check txn_owner and txn_access_list. For any invalid users, append to bad_changes and return True. """ # The owner and names in access list must be real users conf = ResourceLocator.default().get_conf() user_tool_name = conf.get_value(["rosa-svn", "user-tool"]) if not user_tool_name: return False user_tool = self.usertools_manager.get_handler(user_tool_name) txn_users = set([txn_owner] + txn_access_list) txn_users.discard("*") bad_users = user_tool.verify_users(txn_users) for bad_user in bad_users: if txn_owner == bad_user: bad_change = BadChange(status, path, BadChange.USER, "owner=" + bad_user) bad_changes.append(bad_change) if bad_user in txn_access_list: bad_change = BadChange(status, path, BadChange.USER, "access-list=" + bad_user) bad_changes.append(bad_change) return bool(bad_users) def run(self, repos, txn): """Apply the rule engine on transaction "txn" to repository "repos".""" changes = set() # set([(status, path), ...]) for line in self._svnlook("changed", "-t", txn, repos).splitlines(): status, path = line.split(None, 1) changes.add((status, path)) bad_changes = [] author = None super_users = None rev_info_map = {} # {path-id: (owner, access-list), ...} txn_info_map = {} conf = ResourceLocator.default().get_conf() ignores_str = conf.get_value(["rosa-svn", "ignores"], self.IGNORES) ignores = shlex.split(ignores_str) for status, path in sorted(changes): if any(fnmatch(path, ignore) for ignore in ignores): continue names = path.split("/", self.LEN_ID + 1) tail = None if not names[-1]: tail = names.pop() # Directories above the suites must match the ID patterns is_bad = False for name, pattern in zip(names, self.RE_ID_NAMES): if not re.compile(r"\A" + pattern + r"\Z").match(name): is_bad = True break if is_bad: bad_changes.append(BadChange(status, path)) continue # Can only add directories at levels above the suites if len(names) < self.LEN_ID: if status[0] != self.ST_ADD: bad_changes.append(BadChange(status, path)) continue else: is_meta_suite = "".join(names[0:self.LEN_ID]) == "ROSIE" # A file at the branch level if len(names) == self.LEN_ID + 1 and tail is None: bad_changes.append(BadChange(status, path)) continue # No need to check non-trunk changes if len(names) > self.LEN_ID and names[self.LEN_ID] != "trunk": continue # New suite should have an info file if status[0] == self.ST_ADD and len(names) == self.LEN_ID: if (self.ST_ADD, path + "trunk/") not in changes: bad_changes.append( BadChange(status, path, BadChange.NO_TRUNK)) continue path_trunk_info_file = path + self.TRUNK_INFO_FILE if ((self.ST_ADD, path_trunk_info_file) not in changes and (self.ST_UPDATE, path_trunk_info_file) not in changes): bad_changes.append( BadChange(status, path, BadChange.NO_INFO)) continue # The rest are trunk changes in a suite path_head = "/".join(names[0:self.LEN_ID]) + "/" path_tail = path[len(path_head):] # For meta suite, make sure keys in keys file can be parsed if is_meta_suite and path_tail == self.TRUNK_KNOWN_KEYS_FILE: out = self._svnlook("cat", "-t", txn, repos, path) try: shlex.split(out) except ValueError: bad_changes.append(BadChange(status, path, BadChange.VALUE)) continue # Suite trunk information file must have an owner # User IDs of owner and access list must be real if (status not in self.ST_DELETE and path_tail == self.TRUNK_INFO_FILE): if path_head not in txn_info_map: txn_info_map[path_head] = self._get_info( repos, path_head, txn) txn_owner, txn_access_list = self._get_access_info( txn_info_map[path_head]) if not txn_owner: bad_changes.append( BadChange(status, path, BadChange.NO_OWNER)) continue if self._verify_users(status, path, txn_owner, txn_access_list, bad_changes): continue reports = DefaultValidators().validate( txn_info_map[path_head], load_meta_config(txn_info_map[path_head], config_type=metomi.rose.INFO_CONFIG_NAME)) if reports: reports_str = get_reports_as_text({None: reports}, path) bad_changes.append( BadChange(status, path, BadChange.VALUE, reports_str)) continue # Can only remove trunk information file with suite if status == self.ST_DELETE and path_tail == self.TRUNK_INFO_FILE: if (self.ST_DELETE, path_head) not in changes: bad_changes.append( BadChange(status, path, BadChange.NO_INFO)) continue # Can only remove trunk with suite if status == self.ST_DELETE and path_tail == "trunk/": if (self.ST_DELETE, path_head) not in changes: bad_changes.append( BadChange(status, path, BadChange.NO_TRUNK)) continue # New suite trunk: ignore the rest if (self.ST_ADD, path_head + "trunk/") in changes: continue # See whether author has permission to make changes if author is None: author = self._svnlook("author", "-t", txn, repos).strip() if super_users is None: super_users = [] for s_key in ["rosa-svn", "rosa-svn-pre-commit"]: value = conf.get_value([s_key, "super-users"]) if value is not None: super_users = shlex.split(value) break if path_head not in rev_info_map: rev_info_map[path_head] = self._get_info(repos, path_head) owner, access_list = self._get_access_info(rev_info_map[path_head]) admin_users = super_users + [owner] # Only admin users can remove the suite if author not in admin_users and not path_tail: bad_changes.append(BadChange(status, path)) continue # Admin users and those in access list can modify everything in # trunk apart from specific entries in the trunk info file if "*" in access_list or author in admin_users + access_list: if path_tail != self.TRUNK_INFO_FILE: continue else: bad_changes.append(BadChange(status, path)) continue # The owner must not be deleted if path_head not in txn_info_map: txn_info_map[path_head] = self._get_info(repos, path_head, txn) txn_owner, txn_access_list = self._get_access_info( txn_info_map[path_head]) if not txn_owner: bad_changes.append(BadChange(status, path, BadChange.NO_OWNER)) continue # Only the admin users can change owner and access list if owner == txn_owner and access_list == txn_access_list: continue if author not in admin_users: if owner != txn_owner: bad_changes.append( BadChange(status, path, BadChange.PERM, "owner=" + txn_owner)) else: # access list bad_change = BadChange( status, path, BadChange.PERM, "access-list=" + " ".join(txn_access_list)) bad_changes.append(bad_change) continue if bad_changes: raise BadChanges(bad_changes) __call__ = run
class RosieSvnPostCommitHook(RosieSvnHook): """A post-commit hook on a Rosie Subversion repository. Update the Rosie discovery database on changes to info file. Notify owner and users on access-list on trunk changes. """ KNOWN_KEYS_FILE_PATH = "R/O/S/I/E/trunk/rosie-keys" REC_COPY_INFO = re.compile(r"\A\s+\(from\s(\S+):r(\d+)\)\s*\Z") def __init__(self, event_handler=None, popen=None): super(RosieSvnPostCommitHook, self).__init__(event_handler, popen) self.usertools_manager = SchemeHandlersManager([self.path], "rosie.usertools", ["get_emails"]) def run(self, repos, revision, no_notification=False): """Update database with changes in a changeset.""" # Lookup prefix of repos # Do nothing if prefix is not registered conf = ResourceLocator.default().get_conf() metomi.rosie.db_node = conf.get(["rosie-db"], no_ignore=True) for key, node in metomi.rosie.db_node.value.items(): if node.is_ignored() or not key.startswith("repos."): continue if os.path.realpath(repos) == os.path.realpath(node.value): prefix = key[len("repos."):] break else: return # Locate Rosie DB of repos dao = RosieWriteDAO(conf.get_value(["rosie-db", "db." + prefix])) # Date-time of this commit os.environ["TZ"] = "UTC" date_time = self._svnlook("date", "-r", revision, repos) date, dtime, _ = date_time.split(None, 2) date = mktime(strptime(" ".join([date, dtime, "UTC"]), self.DATE_FMT)) # Detail of changes changeset_attribs = { "repos": repos, "revision": revision, "prefix": prefix, "author": self._svnlook("author", "-r", revision, repos).strip(), "date": date, } branch_attribs_dict = self._get_suite_branch_changes(repos, revision) for key, branch_attribs in sorted(branch_attribs_dict.items()): # Update known keys in suite info database meta table if branch_attribs["has_changed_known_keys_file"]: self._update_known_keys(dao, changeset_attribs) # Update suite info database self._update_info_db(dao, changeset_attribs, branch_attribs) # Notification on trunk changes # Notification on owner and access-list changes if not no_notification and branch_attribs["branch"] == "trunk": self._notify_trunk_changes(changeset_attribs, branch_attribs) def _get_suite_branch_changes(self, repos, revision): """Retrieve changed statuses.""" branch_attribs_dict = {} changed_lines = self._svnlook("changed", "--copy-info", "-r", revision, repos).splitlines(True) while changed_lines: changed_line = changed_lines.pop(0) # A normal status changed_line # Column 1: content status # Column 2: tree status # Column 3: "+" sign denotes a copy history # Column 5+: path path = changed_line[4:].strip() path_status = changed_line[0] if path.endswith("/") and path_status == "_": # Ignore property change on a directory continue # Path must be (under) a valid suite branch, including the special # ROSIE suite names = path.split("/", self.LEN_ID + 1) if len(names) < self.LEN_ID + 1 or ( "".join(names[0:self.LEN_ID]) != "ROSIE" and any( name not in id_chars for name, id_chars in zip(names, self.ID_CHARS_LIST))): continue sid = "".join(names[0:self.LEN_ID]) branch = names[self.LEN_ID] if branch: # Change to a path in a suite branch if (sid, branch) not in branch_attribs_dict: branch_attribs_dict[( sid, branch)] = self._new_suite_branch_change(sid, branch) branch_attribs = branch_attribs_dict[(sid, branch)] try: tail = names[self.LEN_ID + 1] except IndexError: tail = None if tail == self.INFO_FILE: # Suite info file change if branch_attribs["info"] is None: try: branch_attribs["info"] = self._load_info( repos, sid, branch, revision=revision, allow_popen_err=True, ) except metomi.rose.config.ConfigSyntaxError as exc: raise InfoFileError(InfoFileError.VALUE, exc) if path_status != self.ST_ADDED: branch_attribs["old_info"] = self._load_info( repos, sid, branch, revision=int(revision) - 1, allow_popen_err=True, ) if (branch_attribs["old_info"] != branch_attribs["info"] and branch_attribs["status"] != self.ST_ADDED): branch_attribs[ "status_info_file"] = self.ST_MODIFIED elif tail: # ROSIE meta known keys file change if path == self.KNOWN_KEYS_FILE_PATH: branch_attribs["has_changed_known_keys_file"] = True if branch_attribs["status"] == self.ST_EMPTY: branch_attribs["status"] = self.ST_MODIFIED elif path_status in [self.ST_ADDED, self.ST_DELETED]: # Branch add/delete branch_attribs["status"] = path_status # Load suite info and old info regardless if branch_attribs["info"] is None: try: branch_attribs["info"] = self._load_info( repos, sid, branch, revision=revision, allow_popen_err=True, ) except metomi.rose.config.ConfigSyntaxError as exc: raise InfoFileError(InfoFileError.VALUE, exc) # Note: if (allowed) popen err, no DB entry will be created if (branch_attribs["old_info"] is None and branch_attribs["status"] == self.ST_DELETED): branch_attribs["old_info"] = self._load_info( repos, sid, branch, revision=int(revision) - 1, allow_popen_err=True, ) # Append changed lines, so they can be used for notification branch_attribs["changed_lines"].append(changed_line) if changed_line[2] == "+": changed_line_2 = changed_lines.pop(0) branch_attribs["changed_lines"].append(changed_line_2) if path_status != self.ST_ADDED or tail: continue # A line containing the copy info for a branch # Column 5+ looks like: (from PATH:rREV) match = self.REC_COPY_INFO.match(changed_line_2) if match: from_path, from_rev = match.groups() branch_attribs_dict[(sid, branch)].update({ "from_path": from_path, "from_rev": from_rev }) elif path_status == self.ST_DELETED: # The suite has been deleted tree_out = self._svnlook("tree", "-r", str(int(revision) - 1), "-N", repos, path) # Include all branches of the suite in the deletion info for tree_line in tree_out.splitlines()[1:]: del_branch = tree_line.strip().rstrip("/") branch_attribs_dict[( sid, del_branch)] = self._new_suite_branch_change( sid, del_branch, { "old_info": self._load_info( repos, sid, del_branch, revision=int(revision) - 1, allow_popen_err=True, ), "status": self.ST_DELETED, "status_info_file": self.ST_EMPTY, "changed_lines": ["D %s/%s/" % (path, del_branch)], }, ) return branch_attribs_dict def _new_suite_branch_change(self, sid, branch, attribs=None): """Return a dict to represent a suite branch change.""" branch_attribs = { "sid": sid, "branch": branch, "from_path": None, "from_rev": None, "has_changed_known_keys_file": False, "old_info": None, "info": None, "status": self.ST_EMPTY, "status_info_file": self.ST_EMPTY, "changed_lines": [], } if attribs: branch_attribs.update(attribs) return branch_attribs def _notify_trunk_changes(self, changeset_attribs, branch_attribs): """Email owner and/or access-list users on changes to trunk.""" # Notify only if users' email addresses can be determined conf = ResourceLocator.default().get_conf() user_tool_name = conf.get_value(["rosa-svn", "user-tool"]) if not user_tool_name: return notify_who_str = conf.get_value( ["rosa-svn", "notify-who-on-trunk-commit"], "") if not notify_who_str.strip(): return notify_who = shlex.split(notify_who_str) # Build the message text info_file_path = "%s/trunk/%s" % ( "/".join(branch_attribs["sid"]), self.INFO_FILE, ) text = "" for changed_line in branch_attribs["changed_lines"]: text += changed_line # For suite info file change, add diff as well if (changed_line[4:].strip() == info_file_path and branch_attribs["status_info_file"] == self.ST_MODIFIED): old_strio = StringIO() metomi.rose.config.dump(branch_attribs["old_info"], old_strio) new_strio = StringIO() metomi.rose.config.dump(branch_attribs["info"], new_strio) for diff_line in unified_diff( old_strio.getvalue().splitlines(True), new_strio.getvalue().splitlines(True), "@%d" % (int(changeset_attribs["revision"]) - 1), "@%d" % (int(changeset_attribs["revision"])), ): text += " " * 4 + diff_line # Determine who to notify users = set() for key in ["old_info", "info"]: if branch_attribs[key] is not None: info_conf = branch_attribs[key] if "owner" in notify_who: users.add(info_conf.get_value(["owner"])) if "access-list" in notify_who: users.update( info_conf.get_value(["access-list"], "").split()) users.discard("*") # Determine email addresses user_tool = self.usertools_manager.get_handler(user_tool_name) if "author" in notify_who: users.add(changeset_attribs["author"]) else: users.discard(changeset_attribs["author"]) emails = sorted(user_tool.get_emails(users)) if not emails: return # Send notification msg = MIMEText(text) msg.set_charset("utf-8") msg["From"] = conf.get_value( ["rosa-svn", "notification-from"], "notications@" + socket.getfqdn(), ) msg["To"] = ", ".join(emails) msg["Subject"] = "%s-%s/trunk@%d" % ( changeset_attribs["prefix"], branch_attribs["sid"], int(changeset_attribs["revision"]), ) smtp_host = conf.get_value(["rosa-svn", "smtp-host"], default="localhost") smtp = SMTP(smtp_host) smtp.sendmail(msg["From"], emails, msg.as_string()) smtp.quit() def _update_info_db(self, dao, changeset_attribs, branch_attribs): """Update the suite info database for a suite branch.""" idx = "{0}-{1}".format(changeset_attribs["prefix"], branch_attribs["sid"]) vc_attrs = { "idx": idx, "branch": branch_attribs["branch"], "revision": changeset_attribs["revision"], } # Latest table try: dao.delete( LATEST_TABLE_NAME, idx=vc_attrs["idx"], branch=vc_attrs["branch"], ) except al.exc.IntegrityError: # idx and branch were just added: there is no previous record. pass if branch_attribs["status"] != self.ST_DELETED: dao.insert(LATEST_TABLE_NAME, **vc_attrs) # N.B. deleted suite branch only has old info info_key = "info" if branch_attribs["status"] == self.ST_DELETED: info_key = "old_info" if branch_attribs[info_key] is None: return # Main table cols = dict(vc_attrs) cols.update({ "author": changeset_attribs["author"], "date": changeset_attribs["date"], }) for name in ["owner", "project", "title"]: cols[name] = branch_attribs[info_key].get_value([name], "null") if branch_attribs["from_path"] and vc_attrs["branch"] == "trunk": from_names = branch_attribs["from_path"].split("/")[:self.LEN_ID] cols["from_idx"] = "{0}-{1}".format(changeset_attribs["prefix"], "".join(from_names)) cols["status"] = (branch_attribs["status"] + branch_attribs["status_info_file"]) dao.insert(MAIN_TABLE_NAME, **cols) # Optional table for name in branch_attribs[info_key].value: if name in ["owner", "project", "title"]: continue value = branch_attribs[info_key].get_value([name]) if value is None: # setting may have ignore flag (!) continue cols = dict(vc_attrs) cols.update({"name": name, "value": value}) dao.insert(OPTIONAL_TABLE_NAME, **cols) def _update_known_keys(self, dao, changeset_attribs): """Update the known_keys in the meta table.""" repos = changeset_attribs["repos"] revision = changeset_attribs["revision"] keys_str = self._svnlook("cat", "-r", revision, repos, self.KNOWN_KEYS_FILE_PATH) keys_str = " ".join(shlex.split(keys_str)) if keys_str: try: dao.insert(META_TABLE_NAME, name="known_keys", value=keys_str) except al.exc.IntegrityError: dao.update( META_TABLE_NAME, ("name", ), name="known_keys", value=keys_str, )
class RosieSvnPreCommitHook(RosieSvnHook): """A pre-commit hook on a Rosie Subversion repository. Ensure that commits conform to the rules of Rosie. """ IGNORES = "svnperms.conf" RE_ID_NAMES = [r"[Ra-z]", r"[Oa-z]", r"[S\d]", r"[I\d]", r"[E\d]"] TRUNK_KNOWN_KEYS_FILE = "trunk/rosie-keys" def __init__(self, event_handler=None, popen=None): super(RosieSvnPreCommitHook, self).__init__(event_handler, popen) self.usertools_manager = SchemeHandlersManager( [self.path], "rosie.usertools", ["verify_users"] ) def _get_access_info(self, info_node): """Return (owner, access_list) from "info_node".""" owner = info_node.get_value(["owner"]) access_list = info_node.get_value(["access-list"], "").split() access_list.sort() return owner, access_list def _verify_users( self, status, path, txn_owner, txn_access_list, bad_changes ): """Check txn_owner and txn_access_list. For any invalid users, append to bad_changes and return True. """ # The owner and names in access list must be real users conf = ResourceLocator.default().get_conf() user_tool_name = conf.get_value(["rosa-svn", "user-tool"]) if not user_tool_name: return False user_tool = self.usertools_manager.get_handler(user_tool_name) txn_users = set([txn_owner] + txn_access_list) txn_users.discard("*") bad_users = user_tool.verify_users(txn_users) for bad_user in bad_users: if txn_owner == bad_user: bad_change = BadChange( status, path, BadChange.USER, "owner=" + bad_user ) bad_changes.append(bad_change) if bad_user in txn_access_list: bad_change = BadChange( status, path, BadChange.USER, "access-list=" + bad_user ) bad_changes.append(bad_change) return bool(bad_users) def run(self, repos, txn): """Apply the rule engine on transaction "txn" to repository "repos".""" changes = set() # set([(status, path), ...]) for line in self._svnlook("changed", "-t", txn, repos).splitlines(): status, path = line.split(None, 1) changes.add((status, path)) bad_changes = [] author = None super_users = None rev_info_map = {} txn_info_map = {} conf = ResourceLocator.default().get_conf() ignores_str = conf.get_value(["rosa-svn", "ignores"], self.IGNORES) ignores = shlex.split(ignores_str) for status, path in sorted(changes): if any(fnmatch(path, ignore) for ignore in ignores): continue names = path.split("/", self.LEN_ID + 1) tail = None if not names[-1]: tail = names.pop() # Directories above the suites must match the ID patterns is_bad = False for name, pattern in zip(names, self.RE_ID_NAMES): if not re.compile(r"\A" + pattern + r"\Z").match(name): is_bad = True break if is_bad: msg = "Directories above the suites must match the ID patterns" bad_changes.append(BadChange(status, path, content=msg)) continue # At levels above the suites, can only add directories if len(names) < self.LEN_ID: if status[0] != self.ST_ADDED: msg = ( "At levels above the suites, " "can only add directories" ) bad_changes.append(BadChange(status, path, content=msg)) continue # Cannot have a file at the branch level if len(names) == self.LEN_ID + 1 and tail is None: msg = "Cannot have a file at the branch level" bad_changes.append(BadChange(status, path, content=msg)) continue # New suite should have an info file if len(names) == self.LEN_ID and status == self.ST_ADDED: if (self.ST_ADDED, path + "trunk/") not in changes: bad_changes.append( BadChange(status, path, BadChange.NO_TRUNK) ) continue path_trunk_info_file = path + self.TRUNK_INFO_FILE if (self.ST_ADDED, path_trunk_info_file) not in changes and ( self.ST_UPDATED, path_trunk_info_file, ) not in changes: bad_changes.append( BadChange(status, path, BadChange.NO_INFO) ) continue sid = "".join(names[0 : self.LEN_ID]) branch = names[self.LEN_ID] if len(names) > self.LEN_ID else None path_head = "/".join(sid) + "/" path_tail = path[len(path_head) :] is_meta_suite = sid == "ROSIE" if status != self.ST_DELETED: # Check info file if sid not in txn_info_map: try: txn_info_map[sid] = self._load_info( repos, sid, branch=branch, transaction=txn ) err = None except ConfigSyntaxError as exc: err = InfoFileError(InfoFileError.VALUE, exc) except RosePopenError as exc: err = InfoFileError(InfoFileError.NO_INFO, exc.stderr) if err: bad_changes.append(err) txn_info_map[sid] = err continue # Suite must have an owner txn_owner, txn_access_list = self._get_access_info( txn_info_map[sid] ) if not txn_owner: bad_changes.append( InfoFileError(InfoFileError.NO_OWNER) ) continue # No need to check other non-trunk changes if branch and branch != "trunk": continue # For meta suite, make sure keys in keys file can be parsed if is_meta_suite and path_tail == self.TRUNK_KNOWN_KEYS_FILE: out = self._svnlook("cat", "-t", txn, repos, path) try: shlex.split(out) except ValueError: bad_changes.append( BadChange(status, path, BadChange.VALUE) ) continue # User IDs of owner and access list must be real if ( status != self.ST_DELETED and path_tail == self.TRUNK_INFO_FILE and not isinstance(txn_info_map[sid], InfoFileError) ): txn_owner, txn_access_list = self._get_access_info( txn_info_map[sid] ) if self._verify_users( status, path, txn_owner, txn_access_list, bad_changes ): continue reports = DefaultValidators().validate( txn_info_map[sid], load_meta_config( txn_info_map[sid], config_type=metomi.rose.INFO_CONFIG_NAME, ), ) if reports: reports_str = get_reports_as_text({None: reports}, path) bad_changes.append( BadChange(status, path, BadChange.VALUE, reports_str) ) continue # Can only remove trunk information file with suite if status == self.ST_DELETED and path_tail == self.TRUNK_INFO_FILE: if (self.ST_DELETED, path_head) not in changes: bad_changes.append( BadChange(status, path, BadChange.NO_INFO) ) continue # Can only remove trunk with suite # (Don't allow replacing trunk with a copy from elsewhere, either) if status == self.ST_DELETED and path_tail == "trunk/": if (self.ST_DELETED, path_head) not in changes: bad_changes.append( BadChange(status, path, BadChange.NO_TRUNK) ) continue # New suite trunk: ignore the rest if (self.ST_ADDED, path_head + "trunk/") in changes: continue # See whether author has permission to make changes if author is None: author = self._svnlook("author", "-t", txn, repos).strip() if super_users is None: super_users = [] for s_key in ["rosa-svn", "rosa-svn-pre-commit"]: value = conf.get_value([s_key, "super-users"]) if value is not None: super_users = shlex.split(value) break if sid not in rev_info_map: rev_info_map[sid] = self._load_info(repos, sid, branch=branch) owner, access_list = self._get_access_info(rev_info_map[sid]) admin_users = super_users + [owner] # Only admin users can remove the suite if author not in admin_users and not path_tail: msg = "Only the suite owner can remove the suite" bad_changes.append(BadChange(status, path, content=msg)) continue # Admin users and those in access list can modify everything in # trunk apart from specific entries in the trunk info file if "*" in access_list or author in admin_users + access_list: if path_tail != self.TRUNK_INFO_FILE: continue else: msg = "User not in access list" bad_changes.append(BadChange(status, path, content=msg)) continue # Only the admin users can change owner and access list if owner == txn_owner and access_list == txn_access_list: continue if author not in admin_users: if owner != txn_owner: bad_changes.append( BadChange( status, path, BadChange.PERM, "owner=" + txn_owner ) ) else: # access list bad_change = BadChange( status, path, BadChange.PERM, "access-list=" + " ".join(txn_access_list), ) bad_changes.append(bad_change) continue if bad_changes: raise BadChanges(bad_changes) __call__ = run