New scmpolicy plugin

Plugin for scm policy using data from SCM checkout.

Related: https://pagure.io/koji/issue/3968
This commit is contained in:
Tomas Kopecek 2023-12-04 16:50:46 +01:00
parent 0251961929
commit 2013692fc9
4 changed files with 185 additions and 1 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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,