From da0ab206b024f9cd382fca532623862858c363a6 Mon Sep 17 00:00:00 2001
From: Merlin Mathesius <mmathesi@redhat.com>
Date: Sep 01 2021 17:44:23 +0000
Subject: Initial implementation


Signed-off-by: Merlin Mathesius <mmathesi@redhat.com>

---

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": "<to>-build-side-<seq_id>",
+                        "from": "<sidetag>-pending-signing",
+                        "to": "<sidetag>-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

