From 5bffca5037f047c1cbac884dcc9e00821cc41fbd Mon Sep 17 00:00:00 2001 From: Tomas Mlcoch Date: Fri, 12 Feb 2016 13:00:19 +0100 Subject: [PATCH] Support signing of rpm wrapped live images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With this patch, you can specify a command for signing of koji builds. For example: signing_key_password_file = '~/file_with_password_for_key_fedora-24' signing_key_id = '81b46521' signing_command = '~/git/releng/scripts/sigulsign_unsigned.py -vv --password=%(signing_key_password)s fedora-24' 'signing_key_password_file' is a path to a file which contains a password that will be formatted into 'signing_command' string via '%(signing_key_password)s' string format syntax (if used). Because pungi config is usualy stored in git and part of compose logs we don't want password to be included directly in the config. Note: If '-' is used instead of a filename, then you will be asked for the password interactivelly right after pungi starts. 'signing_key_id' is ID of the key that will be used for the signing. This ID will be used when crafting koji paths to signed files (kojipkgs.fedoraproject.org/packages/NAME/VER/REL/data/signed/KEYID/..). 'signing_command' a command that will be run with a build as a single argument. This command mustn't require any user interaction. If you need to pass a password for a signing key to the command, do this via command line option of the command with use of string formatting syntax '%(signing_key_password)s' (see details about 'signing_key_password_file'). Signed-off-by: Tomáš Mlčoch --- bin/pungi-koji | 37 ++++++++++++ pungi/phases/live_images.py | 105 +++++++++++++++++++++++++++++++++- tests/test_liveimagesphase.py | 2 + 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/bin/pungi-koji b/bin/pungi-koji index 6ed8c046..6217f61c 100755 --- a/bin/pungi-koji +++ b/bin/pungi-koji @@ -253,6 +253,43 @@ def run_compose(compose): print(i) sys.exit(1) + # PREP + + # Note: This may be put into a new method of phase classes (e.g. .prep()) + # in same way as .validate() or .run() + + # Prep for liveimages - Obtain a password for signing rpm wrapped images + if ("signing_key_password_file" in compose.conf + and "signing_command" in compose.conf + and "%(signing_key_password)s" in compose.conf["signing_command"] + and not liveimages_phase.skip()): + # TODO: Don't require key if signing is turned off + # Obtain signing key password + signing_key_password = None + + # Use appropriate method + if compose.conf["signing_key_password_file"] == "-": + # Use stdin (by getpass module) + try: + signing_key_password = getpass.getpass("Signing key password: ") + except EOFError: + compose.log_debug("Ignoring signing key password") + pass + else: + # Use text file with password + try: + signing_key_password = open(compose.conf["signing_key_password_file"], "r").readline().rstrip('\n') + except IOError: + # Filename is not print intentionally in case someone puts password directly into the option + err_msg = "Cannot load password from file specified by 'signing_key_password_file' option" + compose.log_error(err_msg) + print(err_msg) + sys.exit(1) + + if signing_key_password: + # Store the password + compose.conf["signing_key_password"] = signing_key_password + # INIT phase init_phase.start() init_phase.stop() diff --git a/pungi/phases/live_images.py b/pungi/phases/live_images.py index 2e220696..feca975f 100644 --- a/pungi/phases/live_images.py +++ b/pungi/phases/live_images.py @@ -22,7 +22,7 @@ import pipes import shutil from kobo.threads import ThreadPool, WorkerThread -from kobo.shortcuts import run +from kobo.shortcuts import run, save_to_file from pungi.wrappers.kojiwrapper import KojiWrapper from pungi.wrappers.iso import IsoWrapper @@ -51,6 +51,21 @@ class LiveImagesPhase(PhaseBase): "expected_types": [list], "optional": True, }, + { + "name": "signing_key_id", + "expected_types": [str], + "optional": True, + }, + { + "name": "signing_key_password_file", + "expected_types": [str], + "optional": True, + }, + { + "name": "signing_command", + "expected_types": [str], + "optional": True, + }, ) def __init__(self, compose): @@ -102,6 +117,7 @@ class LiveImagesPhase(PhaseBase): "ksurl": None, "specfile": None, "scratch": False, + "sign": False, "label": "", # currently not used } @@ -129,6 +145,10 @@ class LiveImagesPhase(PhaseBase): # For other images is scratch always on cmd["scratch"] = data.get("scratch", False) + # Signing of the rpm wrapped image + if not cmd["scratch"] and data.get("sign"): + cmd["sign"] = True + format = "%(compose_id)s-%(variant)s-%(arch)s-%(disc_type)s%(disc_num)s%(suffix)s" # Custom name (prefix) if cmd["name"]: @@ -225,6 +245,14 @@ class CreateLiveImageThread(WorkerThread): # copy finished rpm to isos/ (if rpm wrapped ISO was built) if cmd["specfile"]: rpm_paths = koji_wrapper.get_wrapped_rpm_path(output["task_id"]) + + if cmd["sign"]: + # Sign the rpm wrapped images and get their paths + compose.log_info("Signing rpm wrapped images in task_id: %s (expected key ID: %s)" % (output["task_id"], compose.conf.get("signing_key_id"))) + signed_rpm_paths = self._sign_image(koji_wrapper, compose, cmd, output["task_id"]) + if signed_rpm_paths: + rpm_paths = signed_rpm_paths + for rpm_path in rpm_paths: shutil.copy2(rpm_path, cmd["wrapped_rpms_path"]) @@ -240,3 +268,78 @@ class CreateLiveImageThread(WorkerThread): dir, filename = os.path.split(iso_path) iso = IsoWrapper() run("cd %s && %s" % (pipes.quote(dir), iso.get_manifest_cmd(filename))) + + def _sign_image(self, koji_wrapper, compose, cmd, koji_task_id): + signing_key_id = compose.conf.get("signing_key_id") + signing_command = compose.conf.get("signing_command") + + if not signing_key_id: + compose.log_warning("Signing is enabled but signing_key_id is not specified") + compose.log_warning("Signing skipped") + return None + if not signing_command: + compose.log_warning("Signing is enabled but signing_command is not specified") + compose.log_warning("Signing skipped") + return None + + # Prepare signing log file + signing_log_file = compose.paths.log.log_file(cmd["build_arch"], "live_images-signing-%s" % os.path.basename(cmd["iso_path"])) + + # Sign the rpm wrapped images + try: + sign_builds_in_task(koji_wrapper, koji_task_id, signing_command, log_file=signing_log_file, signing_key_password=compose.conf.get("signing_key_password")) + except RuntimeError: + compose.log_error("Error while signing rpm wrapped images. See log: %s" % signing_log_file) + raise + + # Get pats to the signed rpms + signing_key_id = signing_key_id.lower() # Koji uses lowercase in paths + rpm_paths = koji_wrapper.get_signed_wrapped_rpms_paths(koji_task_id, signing_key_id) + + # Wait untill files are available + if wait_paths(rpm_paths, 60*15): + # Files are ready + return rpm_paths + + # Signed RPMs are not available + compose.log_warning("Signed files are not available: %s" % rpm_paths) + compose.log_warning("Unsigned files will be used") + return None + + +def wait_paths(paths, timeout=60): + started = time.time() + remaining = paths[:] + while True: + for path in remaining[:]: + if os.path.exists(path): + remaining.remove(path) + if not remaining: + break + time.sleep(1) + if timeout >= 0 and (time.time() - started) > timeout: + return False + return True + + +def sign_builds_in_task(koji_wrapper, task_id, signing_command, log_file=None, signing_key_password=None): + # Get list of nvrs that should be signed + nvrs = koji_wrapper.get_build_nvrs(task_id) + if not nvrs: + # No builds are available (scratch build, etc.?) + return + + # Append builds to sign_cmd + for nvr in nvrs: + signing_command += " '%s'" % nvr + + # Log signing command before password is filled in it + if log_file: + save_to_file(log_file, signing_command, append=True) + + # Fill password into the signing command + if signing_key_password: + signing_command = signing_command % {"signing_key_password": signing_key_password} + + # Sign the builds + run(signing_command, can_fail=False, show_cmd=False, logfile=log_file) diff --git a/tests/test_liveimagesphase.py b/tests/test_liveimagesphase.py index ae382a1b..e0968b1e 100755 --- a/tests/test_liveimagesphase.py +++ b/tests/test_liveimagesphase.py @@ -95,6 +95,7 @@ class TestLiveImagesPhase(unittest.TestCase): 'iso_path': '/iso_dir/amd64/Client/image-name', 'version': None, 'specfile': None, + 'sign': False, 'type': 'live', 'ksurl': None}, compose.variants['Client'], @@ -140,6 +141,7 @@ class TestLiveImagesPhase(unittest.TestCase): 'iso_path': '/iso_dir/amd64/Client/image-name', 'version': None, 'specfile': None, + 'sign': False, 'type': 'appliance', 'ksurl': 'https://git.example.com/kickstarts.git?#CAFEBABE'}, compose.variants['Client'],