From 2013692fc94c25edd4b55e49e5e215285e4835d3 Mon Sep 17 00:00:00 2001 From: Tomas Kopecek Date: Feb 09 2024 13:47:37 +0000 Subject: New scmpolicy plugin Plugin for scm policy using data from SCM checkout. Related: https://pagure.io/koji/issue/3968 --- diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index be760f1..7f27895 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -442,3 +442,45 @@ 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 policy +========== + +This plugin adds additional policy check after content is checked out from SCM. +New policy is simply named ``scm``. + +Data which can be checked there contains ``build_tag``, ``method``, +``scratch``, and ``branches`` fields. Especially ``branches`` is the reason - +policy can e.g. check if reference being built is part of any allowed branch +and e.g. not random commit which can disappear later. Two new policy tests are +part of the plugin ``match_any`` and ``match_all`` which tests the list +against glob. So, in this case any (or all respectively) branch must pass the +glob test. + + +Example policy: + +:: + + scm = + # anything can be built as a scratch build + bool scratch :: allow + + # regular build must be present at lease on one branch + match_all branches * !! deny Source ref must be contained in a branch + + # Combination of method, scm and repo + method buildContainer && buildtag container-test-* && match scm_host git.example.com && match scm_repository /containers/* :: allow + + # deny any other buildContainer task + method buildContainer :: deny Only specific buildContainer tasks can be executed + + # allow anything else + all :: allow + +Builder +------- + +Plugin is simply activated by adding it as ``plugin = scmpolicy`` to +``/etc/kojid.conf``. No other configuration is required. diff --git a/koji/policy.py b/koji/policy.py index 729e02e..8a57057 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 0000000..f120e33 --- /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 diff --git a/tests/test_lib/test_policy.py b/tests/test_lib/test_policy.py index 123b1b8..4ef0e7a 100644 --- a/tests/test_lib/test_policy.py +++ b/tests/test_lib/test_policy.py @@ -113,6 +113,23 @@ class TestBasicTests(unittest.TestCase): koji.policy.CompareTest('some thing LOL 2') + def test_match_any_test(self): + obj = koji.policy.MatchAnyTest('not_important foo *bar* ext') + self.assertTrue(obj.run({'foo': ['barrrr', 'any']})) + self.assertTrue(obj.run({'foo': [None, 'bbbbbarrr', None]})) + self.assertFalse(obj.run({'foo': ['nah....']})) + self.assertFalse(obj.run({'foo': 'nah...'})) + self.assertFalse(obj.run({'bar': ['any']})) + + def test_match_all_test(self): + obj = koji.policy.MatchAllTest('not_important foo *bar* ext') + self.assertTrue(obj.run({'foo': ['barrrr', 'bbbarrr']})) + self.assertFalse(obj.run({'foo': ['barrrr', 'nah....']})) + self.assertFalse(obj.run({'foo': [None, 'barrrr', None]})) + self.assertFalse(obj.run({'foo': 'nah...'})) + self.assertFalse(obj.run({'bar': ['any']})) + + class TestDiscovery(unittest.TestCase): def test_find_simple_tests(self): @@ -124,6 +141,8 @@ class TestDiscovery(unittest.TestCase): 'false': koji.policy.FalseTest, 'has': koji.policy.HasTest, 'match': koji.policy.MatchTest, + 'match_all': koji.policy.MatchAllTest, + 'match_any': koji.policy.MatchAnyTest, 'none': koji.policy.NoneTest, 'target': koji.policy.TargetTest, 'true': koji.policy.TrueTest,