From da0ab206b024f9cd382fca532623862858c363a6 Mon Sep 17 00:00:00 2001 From: Merlin Mathesius Date: Sep 01 2021 17:44:23 +0000 Subject: Initial implementation Signed-off-by: Merlin Mathesius --- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..050f601 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +lib/__pycache__ +/.tox/ diff --git a/.packit.yaml b/.packit.yaml new file mode 100644 index 0000000..3c73c23 --- /dev/null +++ b/.packit.yaml @@ -0,0 +1,14 @@ +# See packit documentation for more information: +# https://packit.dev/docs/configuration/ + +specfile_path: fedora/python-calligrabot.spec +upstream_package_name: calligrabot +upstream_project_url: https://pagure.io/calligrabot +downstream_package_name: python-calligrabot + +actions: + get-current-version: python3 ./setup.py --version + +synced_files: + - fedora/ + - .packit.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5034a0d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Red Hat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1f64e2a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +global-exclude *.pyc *.pyo +include LICENSE +include README.md +include requirements.txt +include test-requirements.txt +recursive-include docs *.txt *.rst *.md +recursive-include calligrabot * diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..475ee6b --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +ifndef PYTHON +PYTHON=$(shell which python3 2>/dev/null || which python 2>/dev/null) +endif +VERSION=$(shell $(PYTHON) setup.py --version 2>/dev/null) +SETUP_DEVELOP_ARGS=$(shell if ($(PYTHON) setup.py develop --help 2>/dev/null | grep -q '\-\-user'); then echo "--user"; else echo ""; fi) + + +default: + @echo + @echo "Targets:" + @echo "test: Run tests (tox)" + @echo "develop: Run setuptools developer build" + @echo "srpm: Create SRPM" + @echo "clean: Purge junk" + @echo + +develop: + $(PYTHON) setup.py develop $(SETUP_DEVELOP_ARGS) + +srpm: + packit srpm + +.PHONY: clean +clean: + $(PYTHON) setup.py clean + rm -rf *.egg-info .tox *.src.rpm + rm -f fedora/*.tar.gz + find . -type f -name '*.pyc' -delete + find . -type d -name '__pycache__' | xargs rm -rf + +.PHONY: test +test: + tox + +variables: + @echo "PYTHON=$(PYTHON)" + @echo "VERSION=$(VERSION)" + @echo "SETUP_DEVELOP_ARGS=$(SETUP_DEVELOP_ARGS)" diff --git a/README.md b/README.md index f6f7732..d4bc6e4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,104 @@ # calligrabot -A robosignatory driver for the CentOS Stream signing service \ No newline at end of file +A [robosignatory](https://pagure.io/robosignatory/) driver for the CentOS +Stream signing service. + +Calligrabot takes care of downloading RPMs from Koji, securely signing them, +and uploading the signed RPMs back to Koji. + +This is deployed via ansible. The CentOS Stream deployment role and the +configuration files are stored in +[this repo](https://github.com/CentOS/ansible-role-robosignatory). + +## Configuration + +"calligrabot" is the name of a robosignatory.signing.helpers setuptools entry point. + +The calligrabot driver is enabled by setting ``backend = "calligrabot"`` +in the ``[consumer_config.signing]`` section of the robosignatory configuration file, +``/etc/fedora-messaging/robosignatory.toml``, +along with specifying the ``principal`` and ``keytab``. + +For example: + +``` +... +[consumer_config] + + [consumer_config.signing] + backend = "calligrabot" + user = "calligrabot" + principal = "autosign/signer.redhat.com@REDHAT.COM" + keytab = "/etc/krb5.signer.redhat.com.keytab" + ima_key = "ima-keyname" + # calligrabot config details are contained within this config file so + # config_file needs to be self-referential + config_file = "/etc/fedora-messaging/robosignatory.toml" + + [consumer_config.koji_instances] + [consumer_config.koji_instances.primary] + url = "https://kojihub.stream.rdu2.redhat.com/kojihub" + weburl = "https://kojihub.stream.centos.org/koji" + topurl = "http://kojihub.stream.centos.org/kojifiles" +... +``` + +# Command Line Usage + +Calligrabot has a command line interface, ``/usr/bin/calligrabot``, which is a +wrapper for Red Hat's internal ``rpm-sign`` utility that takes care of the actual +secure signing of RPMs. +``/usr/bin/calligrabot`` is also called directly by the "calligrabot" robosignatory +driver. + +``` +$ calligrabot --help +Usage: calligrabot [OPTIONS] COMMAND [ARGS]... + +Options: + -d, --debug Enable debugging output + -n, --dry-run Dry run mode + -c, --config-file FILE Path to the configuration file + -u, --user-name TEXT User name + -p, --principal TEXT Kerberos principal + -k, --keytab TEXT Kerberos keytab + --help Show this message and exit. + +Commands: + sign-rpms + +$ calligrabot sign-rpms --help +Usage: calligrabot sign-rpms [OPTIONS] KEYNAME [RPMS]... + +Options: + -i, --koji-instance TEXT Use the specified Koji instance + --help Show this message and exit. + +$ calligrabot -u _username_ sign-rpms _key_ howdy-1-1.el9.noarch.rpm +``` + +## Development + +### Code style + +Please format code using `black -l 79`. + +### Unit-testing + +Install packages required to test the python scripts: + +``` +$ sudo dnf install -y \ + python3-robosignatory \ + tox +``` + +Run the tests: + +``` +$ make test +``` + +## License + +MIT (see [LICENSE](LICENSE) file) diff --git a/calligrabot/__init__.py b/calligrabot/__init__.py new file mode 100644 index 0000000..548d2d4 --- /dev/null +++ b/calligrabot/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: MIT diff --git a/calligrabot/driver.py b/calligrabot/driver.py new file mode 100644 index 0000000..b39e9c8 --- /dev/null +++ b/calligrabot/driver.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: MIT + +import logging + +from robosignatory.utils import BaseSigningHelper + + +log = logging.getLogger("calligrabot.signer") + + +class RPMSigningHelper(BaseSigningHelper): + def __init__( + self, + user, + principal, + keytab, + ima_key=None, + config_file=None, + ): + self.user = user + self.principal = principal + self.keytab = keytab + self.ima_key = ima_key + self.config_file = config_file + + def build_cmdline(self, *args): + cmdline = [ + "calligrabot", + "--user-name", + self.user, + ] + if self.principal: + cmdline.extend(["--principal", self.principal]) + if self.keytab: + cmdline.extend(["--keytab", self.keytab]) + if self.ima_key: + cmdline.extend(["--ima-sign-key", self.ima_key]) + if self.config_file: + cmdline.extend(["--config-file", self.config_file]) + cmdline.extend(args) + return cmdline + + def build_sign_cmdline( + self, key, rpms, koji_instance, file_signing_key=None + ): + # Note: robosignatory supplies file_signing_key as a positional + # argument, but it's not needed here + + command = self.build_cmdline("sign-rpms") + + if koji_instance: + command.extend(["--koji-instance", koji_instance]) + + if file_signing_key: + log.debug("Ignoring unneeded signing key: %s", file_signing_key) + + command.append(key) + + return command + rpms + + def build_atomic_cmdline(self, *args, **kwargs): + result = [ + "echo", + " ".join(["build_atomic_cmdline:", str(args), str(kwargs)]), + ] + log.info(result) + return result + + def build_coreos_cmdline(self, *args, **kwargs): + result = [ + "echo", + " ".join(["build_coreos_cmdline:", str(args), str(kwargs)]), + ] + log.info(result) + return result diff --git a/calligrabot/utils.py b/calligrabot/utils.py new file mode 100644 index 0000000..9a8803f --- /dev/null +++ b/calligrabot/utils.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: MIT + +import logging +import os +import subprocess + +from time import sleep + +log = logging.getLogger("calligrabot.signer") + + +def run_command(cmd, error_msg="", retries=6, backoff_factor=3, **kwargs): + """ + Wrap around _run_command to throw an exception when the + command fails to run and set up retries. + + :param cmd: Command to run. + :param error_msg: Custom error message to show upon failure. + :param retries: How many times to retry during a failure, 0 for no retries. + :param backoff_factor: Number of seconds to use for exponential backoff. + """ + sleep_time = 0 + for i in range(retries + 1): + sleep(sleep_time) + log.debug( + "Executing command (Attempt #{}): {}".format(str(i + 1), cmd) + ) + ret, stdout, stderr = _run_command(cmd, **kwargs) + if stdout: + log.debug("stdout: {}".format(stdout)) + + if ret == 0: + return ret, stdout, stderr + + if stderr: + log.debug("stderr: {}".format(stderr)) + + sleep_time = backoff_factor ** i + else: + if not error_msg: + error_msg = "Failed to run command: {}. ".format(cmd) + + error_msg += "stderr: {}".format(stderr) + raise Exception(error_msg) + + +def _run_command(cmd, **kwargs): + """ + Run a command. + + :param cmd: Command to run. + :param kwargs: Additional keyword arguments. + :return: Tuple return code, stdout, stderr. + """ + + child = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **kwargs + ) + stdout, stderr = child.communicate() + ret = child.wait() + return ret, stdout, stderr + + +def _build_koji_cmd(koji_profile): + """ + Build command line to run koji with specified koji instance. + + :param koji_profile: Properties describing configured koji instance. + :return: List containing command to invoke koji. + """ + + return [ + "/usr/bin/koji", + "--server={}".format(koji_profile["server"]), + "--weburl={}".format(koji_profile["weburl"]), + "--topurl={}".format(koji_profile["topurl"]), + ] + + +def download_build_rpm(koji_profile, rpm, path): + """ + Download one rpm from koji. + + :param koji_profile: Properties describing configured koji instance. + :param rpm: Full name-version-release.arch.rpm RPM name. + :param path: Directory path to which the RPM file should be downloaded. + :returns: Full path to the downloaded RPM. + """ + log.debug("Downloading {} from koji".format(rpm)) + + koji_cmd = _build_koji_cmd(koji_profile) + koji_cmd.extend( + [ + "download-build", + "--rpm", + "--debuginfo", + "--noprogress", + rpm, + ] + ) + _, stdout, _ = run_command(koji_cmd, cwd=path) + + rpmfile = os.path.join(path, rpm) + + if not os.path.isfile(rpmfile): + raise Exception("Downloaded RPM file {} is missing.".format(rpmfile)) + + log.debug("Downloaded {} successfully".format(rpmfile)) + + return rpmfile + + +def import_rpm_signatures(koji_profile, rpmfiles, dry_run=False): + """ + Import signed rpms to koji. + + :param koji_profile: Properties describing configured koji instance. + :param rpmfiles: List of RPM files to upload + :param dry_run: True to make no changes + :returns: + """ + log.debug("Importing RPM signatures from {} to koji".format(rpmfiles)) + + koji_cmd = _build_koji_cmd(koji_profile) + koji_cmd.extend(["import-sig"]) + if dry_run: + koji_cmd.extend(["--test"]) + koji_cmd.extend(rpmfiles) + _, stdout, _ = run_command(koji_cmd) + + log.debug("Import of RPM signatures successful") + + +def generate_signing_cmdline( + keyname, + files_to_sign, + principal=None, + keytab=None, + gpgsign=False, + imasign=False, + output_file=None, + onbehalf="", + nat=False, + verbose=False, + *args +): + """ + Build the signing command to run. + + :param keyname: Name of the signing key. + :param files_to_sign: List of the filenames of the RPM builds. + :param principal: Kerberos principal for host authentication. + :param keytab: Kerberos keytab for host authentication. + :param gpgsign: Indicates whether it is a gpg/clear sign. Defaulted to + False, which is for rpm sign. + :param imasign: Indicates whether it is an IMA sign. Defaulted to + False, which is for regular sign. + :param output_file: Path to a file to send output to. + :param onbehalf: Person who is triggered the signing. + :param nat: True if application runs behind a NAT. + :param verbose: True to run signing command with increased verbosity. + :param args: List of extra args to append to signing command line. + :return: List containing command line to invoke signing. + """ + cmdline = ["rpm-sign"] + + if verbose: + cmdline.extend(["--verbose"]) + + cmdline.extend(["--key", keyname]) + if imasign: + cmdline.extend(["--imasign"]) + cmdline.extend(files_to_sign) + + if not principal: + raise ValueError("Required Kerberos principal is missing") + if not keytab: + raise ValueError("Required Kerberos keytab is missing") + cmdline.extend(["--principal", principal, "--keytab", keytab]) + + if gpgsign: + cmdline.extend(["--gpgsign"]) + + if output_file: + cmdline.extend(["--output", output_file]) + + if not onbehalf: + raise ValueError("Required requester information is missing") + cmdline.extend(["--onbehalfof", onbehalf]) + + if nat: + cmdline.extend(["--nat"]) + + cmdline.extend(args) + + return cmdline diff --git a/calligrabot/wrapper.py b/calligrabot/wrapper.py new file mode 100644 index 0000000..3048485 --- /dev/null +++ b/calligrabot/wrapper.py @@ -0,0 +1,215 @@ +# SPDX-License-Identifier: MIT + +import click +import logging +import os +import rpm_head_signing +import tempfile + +from fedora_messaging.config import conf + +from calligrabot.utils import ( + download_build_rpm, + generate_signing_cmdline, + import_rpm_signatures, + run_command, +) + +log = logging.getLogger("calligrabot") + + +class UserObject: + pass + + +@click.group() +@click.option( + "-d", + "--debug", + is_flag=True, + help="Enable debugging output", +) +@click.option( + "-n", + "--dry-run", + is_flag=True, + help="Dry run mode", +) +@click.option( + "-c", + "--config-file", + default="/etc/fedora-messaging/robosignatory.toml", + type=click.Path(exists=True, dir_okay=False), + help="Path to the configuration file", +) +@click.option( + "-u", "--user-name", required=True, type=click.STRING, help="User name" +) +@click.option( + "-p", "--principal", type=click.STRING, help="Kerberos principal" +) +@click.option("-k", "--keytab", type=click.STRING, help="Kerberos keytab") +@click.pass_context +def cli(ctx, dry_run, debug, config_file, user_name, principal, keytab): + if debug: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + + ctx.obj = UserObject() + + if not os.path.isfile(config_file): + raise click.exceptions.BadParameter( + "{} is not a file".format(config_file) + ) + + conf.load_config(config_path=config_file) + ctx.obj.cfg = conf["consumer_config"] + + ctx.obj.args = {} + ctx.obj.args["debug"] = debug + ctx.obj.args["dry_run"] = dry_run + ctx.obj.args["user_name"] = user_name + ctx.obj.args["principal"] = principal + ctx.obj.args["keytab"] = keytab + + +@cli.command("sign-rpms") +@click.option( + "-i", + "--koji-instance", + default="primary", + help="Use the specified Koji instance", +) +@click.option( + "-I", "--ima-sign-key", type=click.STRING, help="IMA signing key" +) +# Signing key name +@click.argument("keyname", nargs=1) +# List of RPM NEVRAs" +@click.argument("rpms", nargs=-1) +@click.pass_context +def sign_rpms( + ctx, + keyname, + rpms, + koji_instance, + ima_sign_key, +): + # print a bunch of debugging information + # to make sure we have everything we need + log.debug("sign_rpms() called") + log.debug("keyname: {}".format(keyname)) + log.debug("rpms: {}".format(rpms)) + log.debug("koji_instance: {}".format(koji_instance)) + log.debug("ima_sign_key: {}".format(ima_sign_key)) + log.debug("debug: {}".format(ctx.obj.args["debug"])) + log.debug("user_name: {}".format(ctx.obj.args["user_name"])) + + koji_profile = { + "instance": koji_instance, + "server": ctx.obj.cfg["koji_instances"][koji_instance]["url"], + "weburl": ctx.obj.cfg["koji_instances"][koji_instance]["weburl"], + "topurl": ctx.obj.cfg["koji_instances"][koji_instance]["topurl"], + } + log.debug("koji server: {}".format(koji_profile["server"])) + log.debug("koji weburl: {}".format(koji_profile["weburl"])) + log.debug("koji topurl: {}".format(koji_profile["topurl"])) + + # if principal or keytab haven't been provided on the command line, + # use the values from the config file + principal = ctx.obj.args["principal"] or ctx.obj.cfg["signing"].get( + "principal" + ) + keytab = ctx.obj.args["keytab"] or ctx.obj.cfg["signing"].get("keytab") + log.debug("principal: {}".format(principal)) + log.debug("keytab: {}".format(keytab)) + + tmpdir = tempfile.TemporaryDirectory(prefix="calligrabot-") + log.info( + "Downloading RPMs {} to temporary directory {}".format( + rpms, tmpdir.name + ) + ) + + if ima_sign_key: + imadigest = os.path.join(tmpdir.name, "ima_digests") + + rpmfiles = list() + for rpm in rpms: + rpmfile = download_build_rpm(koji_profile, rpm, tmpdir.name) + rpmfiles.append(rpmfile) + if ima_sign_key: + rpm_head_signing.extract_header( + rpmfile, rpmfile + ".hdr", imadigest + ) + + log.info( + "Signing RPM files {} with key {}.".format( + rpmfiles, + keyname, + ) + ) + sign_cmd = generate_signing_cmdline( + keyname, + rpmfiles, + principal=principal, + keytab=keytab, + onbehalf=ctx.obj.args["user_name"], + verbose=ctx.obj.args["debug"], + ) + + if ctx.obj.args["dry_run"]: + log.info( + "Dry run mode, not running signing command: {}".format(sign_cmd) + ) + else: + log.info("Running signing command: {}".format(sign_cmd)) + run_command(sign_cmd) + + if ima_sign_key: + log.info( + "IMA signing header digest {} with key {}.".format( + imadigest, + ima_sign_key, + ) + ) + sign_cmd = generate_signing_cmdline( + ima_sign_key, + [imadigest], + principal=principal, + keytab=keytab, + imasign=True, + onbehalf=ctx.obj.args["user_name"], + verbose=ctx.obj.args["debug"], + ) + + if ctx.obj.args["dry_run"]: + log.info( + "Dry run mode, not running IMA signing command: {}".format( + sign_cmd + ) + ) + else: + log.info("Running IMA signing command: {}".format(sign_cmd)) + run_command(sign_cmd) + + for rpmfile in rpmfiles: + if ctx.obj.args["dry_run"]: + log.info( + "Dry run mode, cannot insert IMA signatures into %s" + % rpmfile + ) + else: + log.info("Inserting IMA signatures into %s" % rpmfile) + rpm_head_signing.insert_signature( + rpmfile, + None, + ima_presigned_path=imadigest + ".signed", + ) + + log.info("Importing RPM signatures to koji") + + import_rpm_signatures( + koji_profile, rpmfiles, dry_run=ctx.obj.args["dry_run"] + ) diff --git a/fedora/python-calligrabot.spec b/fedora/python-calligrabot.spec new file mode 100644 index 0000000..7fbbb52 --- /dev/null +++ b/fedora/python-calligrabot.spec @@ -0,0 +1,61 @@ +Name: python-calligrabot +Version: 0.0.0 +Release: 0%{?dist} +Summary: A robosignatory driver for the CentOS Stream signing service + +License: MIT +URL: https://pagure.io/calligrabot/ +Source0: calligrabot-0.0.0.tar.gz + +BuildArch: noarch + +BuildRequires: koji +BuildRequires: python3-devel +BuildRequires: python3-robosignatory >= 0.7.0 +BuildRequires: python3-setuptools + +# For tests +BuildRequires: python3-mock +BuildRequires: python3-pytest +BuildRequires: python3-rpm-head-signing + +%description +A robosignatory driver for the CentOS Stream signing service. + +%package -n python3-calligrabot +Summary: %summary +Requires: koji +Requires: python3-robosignatory >= 0.7.0 +Requires: python3-rpm-head-signing + +%description -n python3-calligrabot +A robosignatory driver for the CentOS Stream signing service. + + +%prep +%setup -q -n calligrabot-0.0.0 +# Remove bundled egg-info in case it exists +rm -rf calligrabot.egg-info + + +%build +%py3_build + + +%install +%py3_install + + +%check +%{__python3} -m pytest -v + + +%files -n python3-calligrabot +%doc README.md +%license LICENSE +%{python3_sitelib}/calligrabot/ +%{python3_sitelib}/calligrabot-%{version}* +%{_bindir}/calligrabot + + +%changelog diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1282534 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[tool.black] +line-length = 79 + +[tool.pytest.ini_options] +# to see log output when running tests, set log_cli = true +log_cli = false +log_cli_level = "DEBUG" +log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f8f658e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +robosignatory>=0.7.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3660ca7 --- /dev/null +++ b/setup.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: MIT + +import os +import distutils +import pkg_resources + +from setuptools import setup +from setuptools.command.test import test + + +class PyTest(test): + user_options = [("pytest-args=", "a", "Arguments to pass to pytest")] + + def initialize_options(self): + test.initialize_options(self) + self.pytest_args = [] + + def finalize_options(self): + test.finalize_options(self) + self.ensure_string_list("pytest_args") + self.test_args = [] + self.test_suite = True + + def run_tests(self): + # import late because pytest is only required for testing + import pytest + + exitcode = pytest.main(self.pytest_args) + if exitcode: + msg = "pytest failed!" + self.announce(msg, distutils.log.ERROR) + raise distutils.errors.DistutilsError(msg) + + +def read_requirements(filename): + specifiers = [] + dep_links = [] + + with open(filename, "r") as f: + for line in f: + if line.startswith(("-r", "#")) or line.strip() == "": + continue + if line.startswith("git+"): + dep_links.append(line.strip()) + else: + specifiers.append(line.strip()) + + return specifiers, dep_links + + +setup_py_path = os.path.dirname(os.path.realpath(__file__)) +requirements_file = os.path.join(setup_py_path, "requirements.txt") +test_requirements_file = os.path.join(setup_py_path, "test-requirements.txt") +install_requires, deps_links = read_requirements(requirements_file) +tests_require, _ = read_requirements(test_requirements_file) +if _: + deps_links.extend(_) + +# workaround for missing koji egg-info in EL8 +try: + pkg_resources.get_distribution("koji") +except pkg_resources.DistributionNotFound: + pass +else: + install_requires.append("koji") + +setup( + name="calligrabot", + version="0.0.0", + description="robosignatory driver for calligrabot", + url="https://pagure.io/calligrabot/", + license="MIT", + install_requires=install_requires, + tests_require=tests_require, + dependency_links=deps_links, + packages=[ + "calligrabot", + ], + entry_points={ + "console_scripts": ["calligrabot = calligrabot.wrapper:cli"], + "robosignatory.signing.helpers": [ + "calligrabot = calligrabot.driver:RPMSigningHelper" + ], + }, + cmdclass={ + "test": PyTest, + }, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..c08307f --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +incremental-progress +koji +mock +pyasn1 +pytest diff --git a/tests/test_driver.py b/tests/test_driver.py new file mode 100644 index 0000000..10c7525 --- /dev/null +++ b/tests/test_driver.py @@ -0,0 +1,303 @@ +# SPDX-License-Identifier: GPL-2.0-or-later + +# most of this has been respectfully pilfered from robosignatory's test_tag.py + +import copy +import logging +import mock + +from fedora_messaging.api import Message +from pkg_resources import parse_version +from pytest import mark, __version__ as pytest_version + +from robosignatory.tag import TagSigner + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +try: + import _pytest.logging +except ImportError: + pass +else: + if not hasattr(_pytest.logging.LogCaptureFixture, "messages"): # noqa + # monkey-patch missing messages property + class MyLogCaptureFixture(_pytest.logging.LogCaptureFixture): + @property + def messages(self): + return [r.getMessage() for r in self.records] + + +requires_caplog = mark.skipif( + parse_version(pytest_version) < parse_version("3.3.0"), + reason="The caplog fixture was introduced in pytest 3.3.0", +) + + +TEST_CONFIG = { + "signing": { + "backend": "calligrabot", + "user": "signing-user", + "principal": "signing/principal@EXAMPLE.COM", + "keytab": "/etc/signing.example.com.keytab", + "ima_key": "ima-keyname", + }, + "koji_instances": { + "test": { + "url": "https://koji.example.com", + "mbs_user": "mbs_user", + "options": { + "authmethod": "kerberos", + "principal": "test@EXAMPLE.COM", + }, + "tags": [ + { + "from": "f31-pending", + "to": "f31", + "key": "fedora-31", + "keyid": "deadbeef", + "sidetags": { + "pattern": "-build-side-", + "from": "-pending-signing", + "to": "-testing", + "trusted_taggers": ["bodhi"], + }, + }, + { + "from": "f30-signing-pending", + "to": "f30-updates-testing-pending", + "key": "fedora-30", + "keyid": "OU812I81B4U", + "type": "plain", + }, + { + "from": "f30-modular-signing-pending", + "to": "f30-modular-updates-testing-pending", + "key": "fedora-30", + "keyid": "OU812I81B4U", + "type": "modular", + }, + { + "from": "f31-pending-fsk", + "to": "f31", + "key": "fedora-31", + "keyid": "deadbeef", + "file_signing_key": "file-sign-key", + }, + { + "from": "f30-signing-pending-fsk", + "to": "f30-updates-testing-pending", + "key": "fedora-30", + "keyid": "OU812I81B4U", + "file_signing_key": "file-sign-key", + "type": "plain", + }, + { + "from": "f30-modular-signing-pending-fsk", + "to": "f30-modular-updates-testing-pending", + "key": "fedora-30", + "keyid": "OU812I81B4U", + "file_signing_key": "file-sign-key", + "type": "modular", + }, + ], + }, + }, + "ostree_refs": {}, + "coreos": { + "bucket": "testing", + "key": "testing", + "aws": { + "access_key": "testing", + "access_secret": "testing", + "region": "us-east-1", + }, + }, +} + + +class MockUtils(mock.MagicMock): + + builds = [ + { + "id": 1, + "nvr": "foo-1-1.fc31", + "rpms": [ + { + "id": 100, + "nvr": "foo-1-1.fc31", + "arch": "x64_64", + "sigkey": "1234", + }, + { + "id": 101, + "nvr": "foo-libs-1-1.fc31", + "arch": "x64_64", + "sigkey": "1234", + }, + ], + }, + { + "id": 2, + "nvr": "bar-0.11-1.fc31", + "rpms": [ + { + "id": 200, + "nvr": "bar-0.11-1.fc31", + "arch": "x64_64", + "sigkey": "2345", + }, + { + "id": 201, + "nvr": "bar-docs-0.11-1.fc31", + "arch": "noarch", + "sigkey": "2345", + }, + ], + }, + { + "id": 3, + "nvr": "gnu-10.0-5.fc31", + "rpms": [ + {"id": 300, "nvr": "gnu-10.0-5.fc31", "arch": "x64_64"}, + ], + }, + ] + + def _get_build(self, build_id): + for b in self.builds: + if b["id"] == build_id: + return b + + def get_rpms(self, koji_client, build_nvr, build_id, sigkey=None): + rpminfo = {} + for rpm in self._get_build(build_id)["rpms"]: + info = {"id": rpm["id"]} + if sigkey: + info["signed"] = rpm["sigkey"] == sigkey + rpminfo["{}.{}".format(rpm["nvr"], rpm["arch"])] = info + return rpminfo + + def get_signing_helper(self, **signing_config): + return mock.MagicMock(signing_config=signing_config) + + def run_command(self, cmdline): + return 0, "", "" + + +class DummyContext(object): + def __enter__(self): + pass + + def __exit__(self, *args): + pass + + +@mock.patch( + "robosignatory.consumer.fedora_messaging.config.conf", + {"consumer_config": TEST_CONFIG}, +) +class TestTagSigner(object): + """Test the Koji tag signer class""" + + test_msg = { + "topic": "org.fedoraproject.prod.buildsys.tag", + "body": { + "name": "foo", + "version": "1", + "release": "1.fc31", + "build_id": 1, + "tag": "f31-pending", + "instance": "test", + }, + } + + def setup_method(self, method): + # Patch in a mock koji session. + with mock.patch( + "robosignatory.tag.koji.ClientSession" + ) as self._koji_ClientSession: + self.tag_signer = TagSigner(config=TEST_CONFIG) + + # Ensure methods can muck around with the test message contents and not + # disturb each other. + self.test_msg = copy.deepcopy(type(self).test_msg) + + @property + def instance_obj(self): + return self.tag_signer.koji_clients["test"] + + @property + def koji_client(self): + return self.instance_obj["client"] + + @requires_caplog + @mock.patch("robosignatory.tag.utils", new_callable=MockUtils()) + @mark.parametrize( + "type_", + ( + ("plain"), + ("modular"), + ), + ) + def test_file_signing_key(self, utils, caplog, type_): + """Test with file signing key""" + caplog.set_level(logging.DEBUG) + + body = self.test_msg["body"] + build_nvr = "{name}-{version}-{release}".format(**body) + body["tag"] = body["tag"] + "-fsk" + from_tag = body["tag"] + build_id = body["build_id"] + tag_conf = self.instance_obj["tags"][from_tag] + to_tag = tag_conf["to"] + build_owner = None + + if type_ == "modular": + body["tag"] = from_tag = "f30-modular-signing-pending-fsk" + to_tag = "f30-modular-updates-testing-pending" + build_owner = TEST_CONFIG["koji_instances"]["test"]["mbs_user"] + + self.koji_client.listTagged.return_value = [ + { + "owner_name": build_owner, + "nvr": build_nvr, + "build_id": build_id, + } + ] + expected_log_msgs = [ + "Signing command line: ['calligrabot'," + " '--user-name', 'signing-user'," + " '--principal', 'signing/principal@EXAMPLE.COM'," + " '--keytab', '/etc/signing.example.com.keytab'," + " '--ima-sign-key', 'ima-keyname'," + " 'sign-rpms'," + " '--koji-instance', 'test'," + " 'fedora-30'," + " 'foo-1-1.fc31.x64_64', 'foo-libs-1-1.fc31.x64_64']", + ] + else: + expected_log_msgs = [ + "Signing command line: ['calligrabot'," + " '--user-name', 'signing-user'," + " '--principal', 'signing/principal@EXAMPLE.COM'," + " '--keytab', '/etc/signing.example.com.keytab'," + " '--ima-sign-key', 'ima-keyname'," + " 'sign-rpms'," + " '--koji-instance', 'test'," + " 'fedora-31'," + " 'foo-1-1.fc31.x64_64', 'foo-libs-1-1.fc31.x64_64']", + ] + + msg = Message(**self.test_msg) + self.tag_signer.consume(msg) + + # Equivalent to caplog.messages but compatible with pytest < 3.7 + logged_messages = [record.getMessage() for record in caplog.records] + + for msg in expected_log_msgs: + assert msg in logged_messages + + self.koji_client.tagBuild.assert_called_once_with( + to_tag, build_id, False, from_tag + ) diff --git a/tests/test_signer.py b/tests/test_signer.py new file mode 100644 index 0000000..e375eba --- /dev/null +++ b/tests/test_signer.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: MIT + +import logging +import pkg_resources +import robosignatory.utils +import calligrabot.driver + + +try: + import unittest2 as unittest +except ImportError: + import unittest + + +logger = logging.getLogger(__name__) + + +class TestSigner(unittest.TestCase): + # make sure our helper function entry point is registered in the expected place + def test_entry_point(self): + points = pkg_resources.iter_entry_points( + "robosignatory.signing.helpers" + ) + self.assertIsNotNone(points) + classes = dict([(point.name, point.load()) for point in points]) + logger.debug( + "Found the following installed signing helpers %r" % classes + ) + self.assertGreaterEqual(len(classes), 1) + self.assertIn("calligrabot", classes) + cls = classes["calligrabot"] + self.assertIs(cls, calligrabot.driver.RPMSigningHelper) + + # make sure robosignatory can find our helper function + def test_find_helper(self): + helper = robosignatory.utils.get_signing_helper( + backend="calligrabot", + user="nobody", + principal="nobody@NOWHERE", + keytab="/etc/nobody.nowhere.keytab", + ) + self.assertIs(type(helper), calligrabot.driver.RPMSigningHelper) + + def test_sign_cmdline(self): + helper = calligrabot.driver.RPMSigningHelper( + user="nobody", + principal="nobody@NOWHERE", + keytab="/etc/nobody.nowhere.keytab", + ) + cmdline = helper.build_sign_cmdline("key", ["rpm1"], "koji-primary") + self.assertEqual( + cmdline, + [ + "calligrabot", + "--user-name", + "nobody", + "--principal", + "nobody@NOWHERE", + "--keytab", + "/etc/nobody.nowhere.keytab", + "sign-rpms", + "--koji-instance", + "koji-primary", + "key", + "rpm1", + ], + ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..15f02ca --- /dev/null +++ b/tox.ini @@ -0,0 +1,25 @@ +[tox] +envlist = flake8,py3 + +[flake8] +ignore = E731,W503 +max-line-length = 100 +exclude = .tox,.git,build,.env + +[testenv] +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = pytest -v {posargs} +setenv = + PYTHONPATH = {toxinidir} +sitepackages = true +allowlist_externals = + flake8 + pytest + +[testenv:flake8] +basepython = python3 +skip_install = true +deps = flake8 +commands = flake8