Blob Blame History Raw
From 1d515927aeb3e3c052fc9208ca71133d9d097fc0 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
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