From 1d515927aeb3e3c052fc9208ca71133d9d097fc0 Mon Sep 17 00:00:00 2001 From: Tomas Kopecek Date: Thu, 13 Apr 2023 11:12:40 +0200 Subject: [PATCH] scmpolicy plugin --- docs/source/defining_hub_policies.rst | 10 +++- docs/source/plugins.rst | 30 ++++++++++- koji/policy.py | 53 +++++++++++++++++++- plugins/builder/scmpolicy.py | 72 +++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 plugins/builder/scmpolicy.py diff --git a/docs/source/defining_hub_policies.rst b/docs/source/defining_hub_policies.rst index a0b67eed..8f9cf2cd 100644 --- a/docs/source/defining_hub_policies.rst +++ b/docs/source/defining_hub_policies.rst @@ -341,5 +341,13 @@ Available tests * the user matched is the user performing the action ``match`` - * matches a field in the data against glob patterns + * matches a field in the data against glob patterns * true if any pattern matches + +``match_any`` + * matches a field (of list/tuple/set type) in the data against glob patterns + * true if any field item matches all patterns + +``match_all`` + * matches a field (of list/tuple/set type) in the data against glob patterns + * true if all field items match any pattern diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index c370709a..d5b2d13f 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -223,7 +223,7 @@ The ``[message]`` section sets parameters for how messages are formed. Currently only one field is understood: * ``extra_limit`` -- the maximum allowed size for ``build.extra`` fields that - appear in messages. If the ``build.extra`` field is longer (in terms of + appear in messages. If the ``build.extra`` field is longer (in terms of json-encoded length), then it will be omitted. The default value is ``0`` which means no limit. @@ -441,3 +441,31 @@ For example: For each RPM in the tag, Koji will use the first signed copy that it finds. In other words, Koji will try the first key (`45719a39`), and if Koji does not have the first key's signature for that RPM, then it will try the second key (`9867c58f`), third key (`38ab71f4`), and so on. + +Scm Policies +============ + +Basic filtering of allowed scms normally happens via standard +``build_from_scm`` hub policy. Nevertheless, some relevant information can be +only gathered after cloning the repo. Typical case is that admin would like to +build content only from some set of allowed branches. If user specify the +commit via hash, we don't have that information in moment of task creation. +Just after cloning we can check existing branches and if the given commit is on +some of the relevant ones. For this purpose there is special +``postSCMCheckout`` plugin ``scmpolicy``. + +Installation happens only on builder via editing ``/etc/kojid.conf`` by adding +``plugin = scmpolicy`` there. Plugin itself is not configured but uses hub +policy ``scm``. Policy data provided there are composed of two parts. First one +are ``scm_*`` values which are same as in ``build_from_scm``. + +.. code:: + + scm = + # allow scratch builds from any commits + bool scratch :: allow + # very safe scm, allow anything from there, but only to special target + match scm_host very.safe.git.org && buildtag testing-build-tag :: allow + match_all branches * !! deny Commit must be present on some branch + match_all branches private-* test-* :: deny Private/testing branches are not allowed + all :: allow diff --git a/koji/policy.py b/koji/policy.py index 729e02e5..8a570575 100644 --- a/koji/policy.py +++ b/koji/policy.py @@ -25,7 +25,7 @@ import logging import six import koji -from koji.util import to_list +from koji.util import to_list, multi_fnmatch class BaseSimpleTest(object): @@ -141,6 +141,57 @@ class MatchTest(BaseSimpleTest): return False +class MatchAnyTest(BaseSimpleTest): + """Matches any item of a list/tuple/set value in the data against glob patterns + + True if any of the expressions matches any item in the list/tuple/set, else False. + If the field doesn't exist or isn't a list/tuple/set, the test returns False + + Syntax: + find field pattern1 [pattern2 ...] + + """ + name = 'match_any' + field = None + + def run(self, data): + args = self.str.split()[1:] + self.field = args[0] + args = args[1:] + tgt = data.get(self.field) + if tgt and isinstance(tgt, (list, tuple, set)): + for i in tgt: + if i is not None and multi_fnmatch(str(i), args): + return True + return False + + +class MatchAllTest(BaseSimpleTest): + """Matches all items of a list/tuple/set value in the data against glob patterns + + True if any of the expressions matches all items in the list/tuple/set, else False. + If the field doesn't exist or isn't a list/tuple/set, the test returns False + + Syntax: + match_all field pattern1 [pattern2 ...] + + """ + name = 'match_all' + field = None + + def run(self, data): + args = self.str.split()[1:] + self.field = args[0] + args = args[1:] + tgt = data.get(self.field) + if tgt and isinstance(tgt, (list, tuple, set)): + for i in tgt: + if i is None or not multi_fnmatch(str(i), args): + return False + return True + return False + + class TargetTest(MatchTest): """Matches target in the data against glob patterns diff --git a/plugins/builder/scmpolicy.py b/plugins/builder/scmpolicy.py new file mode 100644 index 00000000..f120e33b --- /dev/null +++ b/plugins/builder/scmpolicy.py @@ -0,0 +1,72 @@ +import logging +import re +import subprocess + +import six + +from koji import ActionNotAllowed, GenericError +from koji.plugin import callback + + +logger = logging.getLogger('koji.plugins.scmpolicy') + + +@callback('postSCMCheckout') +def assert_scm_policy(clb_type, *args, **kwargs): + taskinfo = kwargs['taskinfo'] + session = kwargs['session'] + build_tag = kwargs['build_tag'] + scminfo = kwargs['scminfo'] + srcdir = kwargs['srcdir'] + scratch = kwargs['scratch'] + + method = get_task_method(session, taskinfo) + + policy_data = { + 'build_tag': build_tag, + 'method': method, + 'scratch': scratch, + 'branches': get_branches(srcdir) + } + + # Merge scminfo into data with "scm_" prefix. And "scm*" are changed to "scm_*". + for k, v in six.iteritems(scminfo): + policy_data[re.sub(r'^(scm_?)?', 'scm_', k)] = v + + logger.info("Checking SCM policy for task %s", taskinfo['id']) + logger.debug("Policy data: %r", policy_data) + + # check the policy + try: + session.host.assertPolicy('scm', policy_data) + logger.info("SCM policy check for task %s: PASSED", taskinfo['id']) + except ActionNotAllowed: + logger.warning("SCM policy check for task %s: DENIED", taskinfo['id']) + raise + + +def get_task_method(session, taskinfo): + """Get the Task method from taskinfo""" + method = None + if isinstance(taskinfo, six.integer_types): + taskinfo = session.getTaskInfo(taskinfo, strict=True) + if isinstance(taskinfo, dict): + method = taskinfo.get('method') + if method is None: + raise GenericError("Invalid taskinfo: %s" % taskinfo) + return method + + +def get_branches(srcdir): + """Determine which remote branches contain the current checkout""" + cmd = ['git', 'branch', '-r', '--contains', 'HEAD'] + proc = subprocess.Popen(cmd, cwd=srcdir, stdout=subprocess.PIPE) + (out, _) = proc.communicate() + status = proc.wait() + if status != 0: + raise Exception('Error getting branches for git checkout') + + # cut off origin/ prefix + branches = [b.strip() for b in out.decode().split('\n') if 'origin/HEAD' not in b and b] + branches = [re.sub('^origin/', '', b) for b in branches] + return branches -- GitLab