|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Sign FBC index images via the container-signing pipeline.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import argparse |
| 7 | +import json |
| 8 | +import logging |
| 9 | +import tempfile |
| 10 | +from pathlib import Path |
| 11 | +from typing import Any |
| 12 | + |
| 13 | +from kubectl import get_configmap |
| 14 | +from logger import logger as LOGGER |
| 15 | +from rh_direct_sign_image import ( |
| 16 | + PYXIS_INSTANCE_MAP, |
| 17 | + SigningItem, |
| 18 | + batch_signing_items, |
| 19 | + filter_already_signed, |
| 20 | + get_signing_keys, |
| 21 | + get_submit_config, |
| 22 | + submit_batches, |
| 23 | + validate_file, |
| 24 | + write_batches, |
| 25 | +) |
| 26 | +from subprocess_cmd import run_cmd |
| 27 | + |
| 28 | + |
| 29 | +def translate_reference(target_index: str) -> str: |
| 30 | + """Translate a quay.io internal reference to a public registry.redhat.io reference. |
| 31 | +
|
| 32 | + Args: |
| 33 | + target_index: Internal quay.io image reference |
| 34 | + (e.g. quay.io/redhat/redhat----fbc-target-index:v4.23). |
| 35 | +
|
| 36 | + Returns: |
| 37 | + The public registry.redhat.io URL for the image. |
| 38 | +
|
| 39 | + Raises: |
| 40 | + ValueError: If no redhat.io entry is found in the translation output. |
| 41 | +
|
| 42 | + """ |
| 43 | + result = run_cmd(["translate-delivery-repo", target_index]) |
| 44 | + entries = json.loads(result.stdout) |
| 45 | + for entry in entries: |
| 46 | + if entry.get("repo") == "redhat.io": |
| 47 | + return entry["url"] |
| 48 | + raise ValueError( |
| 49 | + f"No redhat.io entry in translate-delivery-repo output for {target_index}" |
| 50 | + ) |
| 51 | + |
| 52 | + |
| 53 | +def collect_fbc_signing_items( |
| 54 | + fbc_results: dict[str, Any], signing_keys: list[str] |
| 55 | +) -> list[SigningItem]: |
| 56 | + """Build signing items from FBC results. |
| 57 | +
|
| 58 | + For each component, translates the target_index to a public reference |
| 59 | + and creates a SigningItem for every (digest, key) combination. |
| 60 | +
|
| 61 | + Args: |
| 62 | + fbc_results: Parsed FBC results JSON containing components. |
| 63 | + signing_keys: List of signing key IDs. |
| 64 | +
|
| 65 | + Returns: |
| 66 | + List of SigningItem objects covering all signing candidates. |
| 67 | +
|
| 68 | + """ |
| 69 | + items: list[SigningItem] = [] |
| 70 | + |
| 71 | + for component in fbc_results.get("components", []): |
| 72 | + target_index = component["target_index"] |
| 73 | + reference = translate_reference(target_index) |
| 74 | + LOGGER.info("Translated %s -> %s", target_index, reference) |
| 75 | + |
| 76 | + rh_registry_repo = component.get("rh-registry-repo", "") |
| 77 | + repository = ( |
| 78 | + rh_registry_repo.split("/", 1)[1] if "/" in rh_registry_repo else rh_registry_repo |
| 79 | + ) |
| 80 | + |
| 81 | + for digest in component.get("image_digests", []): |
| 82 | + for key in signing_keys: |
| 83 | + items.append(SigningItem(reference, digest, repository, key)) |
| 84 | + |
| 85 | + return items |
| 86 | + |
| 87 | + |
| 88 | +def setup_argparser() -> argparse.ArgumentParser: |
| 89 | + """Build and return the CLI argument parser. |
| 90 | +
|
| 91 | + Returns: |
| 92 | + Configured argument parser. |
| 93 | +
|
| 94 | + """ |
| 95 | + parser = argparse.ArgumentParser(description="Sign FBC index images.") |
| 96 | + parser.add_argument( |
| 97 | + "--fbc-results", |
| 98 | + required=True, |
| 99 | + type=validate_file, |
| 100 | + help="Path to the FBC results JSON file", |
| 101 | + ) |
| 102 | + parser.add_argument( |
| 103 | + "--pyxis-server", |
| 104 | + required=True, |
| 105 | + choices=PYXIS_INSTANCE_MAP.keys(), |
| 106 | + help="Pyxis server instance to use", |
| 107 | + ) |
| 108 | + parser.add_argument( |
| 109 | + "--data-file", |
| 110 | + required=True, |
| 111 | + type=validate_file, |
| 112 | + help="Path to the merged release data JSON file", |
| 113 | + ) |
| 114 | + parser.add_argument( |
| 115 | + "--output", |
| 116 | + type=Path, |
| 117 | + default=None, |
| 118 | + help="Directory where batch files are written (default: temp dir)", |
| 119 | + ) |
| 120 | + parser.add_argument( |
| 121 | + "--batch-max-size", |
| 122 | + type=int, |
| 123 | + default=14 * 1024, |
| 124 | + help="Maximum size in bytes of each base64-encoded batch (default: %(default)s)", |
| 125 | + ) |
| 126 | + parser.add_argument( |
| 127 | + "--fail-on-lookup-error", |
| 128 | + default="true", |
| 129 | + help="Fail when Pyxis lookups fail; set to 'false' to skip filtering on error", |
| 130 | + ) |
| 131 | + parser.add_argument( |
| 132 | + "--max-workers", |
| 133 | + type=int, |
| 134 | + default=10, |
| 135 | + help="Maximum concurrent Pyxis lookup threads (default: %(default)s)", |
| 136 | + ) |
| 137 | + parser.add_argument( |
| 138 | + "--verbose", |
| 139 | + action="store_true", |
| 140 | + help="Enable debug logging", |
| 141 | + ) |
| 142 | + |
| 143 | + submit = parser.add_argument_group("request submission") |
| 144 | + submit.add_argument( |
| 145 | + "--pipeline", |
| 146 | + default="container-signing", |
| 147 | + help="Internal pipeline name for signing (default: %(default)s)", |
| 148 | + ) |
| 149 | + submit.add_argument( |
| 150 | + "--pipeline-image", |
| 151 | + required=True, |
| 152 | + help="Container image override for the signing pipeline", |
| 153 | + ) |
| 154 | + submit.add_argument( |
| 155 | + "--requester", |
| 156 | + required=True, |
| 157 | + help="Name of the user requesting signing, for auditing", |
| 158 | + ) |
| 159 | + submit.add_argument( |
| 160 | + "--request-timeout", |
| 161 | + default="1800", |
| 162 | + help="InternalRequest timeout in seconds (default: %(default)s)", |
| 163 | + ) |
| 164 | + submit.add_argument( |
| 165 | + "--pipeline-timeout", |
| 166 | + default="0h30m0s", |
| 167 | + help="Pipeline timeout (default: %(default)s)", |
| 168 | + ) |
| 169 | + submit.add_argument( |
| 170 | + "--task-timeout", |
| 171 | + default="0h25m0s", |
| 172 | + help="Task timeout (default: %(default)s)", |
| 173 | + ) |
| 174 | + submit.add_argument( |
| 175 | + "--service-account", |
| 176 | + default="signing-pipeline-sa", |
| 177 | + help="Service account for the signing pipeline (default: %(default)s)", |
| 178 | + ) |
| 179 | + submit.add_argument( |
| 180 | + "--task-id", |
| 181 | + default="", |
| 182 | + help="Task run UID used as a label on internal requests", |
| 183 | + ) |
| 184 | + submit.add_argument( |
| 185 | + "--pipelinerun-uid", |
| 186 | + default="", |
| 187 | + help="Pipeline run UID used as a label on internal requests", |
| 188 | + ) |
| 189 | + submit.add_argument( |
| 190 | + "--signing-repo", |
| 191 | + default="https://gitlab.cee.redhat.com/signing/signing.git", |
| 192 | + help="Git repository URL for signing tasks (default: %(default)s)", |
| 193 | + ) |
| 194 | + submit.add_argument( |
| 195 | + "--signing-revision", |
| 196 | + default="main", |
| 197 | + help="Git revision in the signing repository (default: %(default)s)", |
| 198 | + ) |
| 199 | + submit.add_argument( |
| 200 | + "--concurrent-limit", |
| 201 | + type=int, |
| 202 | + default=8, |
| 203 | + help="Maximum number of parallel signing requests (default: %(default)s)", |
| 204 | + ) |
| 205 | + |
| 206 | + return parser |
| 207 | + |
| 208 | + |
| 209 | +def main() -> int: |
| 210 | + """Entry point for FBC index image signing.""" |
| 211 | + parser = setup_argparser() |
| 212 | + |
| 213 | + args = parser.parse_args() |
| 214 | + LOGGER.setLevel(logging.DEBUG if args.verbose else logging.INFO) |
| 215 | + |
| 216 | + pyxis_url = PYXIS_INSTANCE_MAP[args.pyxis_server] |
| 217 | + LOGGER.info("Using Pyxis instance: %s", pyxis_url) |
| 218 | + |
| 219 | + data_file = json.loads(args.data_file.read_text()) |
| 220 | + config_map_name = data_file.get("sign", {}).get("configMapName", "signing-config-map") |
| 221 | + configmap = get_configmap(config_map_name) |
| 222 | + signing_keys = get_signing_keys(configmap) |
| 223 | + LOGGER.info("Signing keys: %s", signing_keys) |
| 224 | + |
| 225 | + fbc_results = json.loads(args.fbc_results.read_text()) |
| 226 | + |
| 227 | + all_items = collect_fbc_signing_items(fbc_results, signing_keys) |
| 228 | + LOGGER.info("Total signing candidates: %d", len(all_items)) |
| 229 | + |
| 230 | + if not all_items: |
| 231 | + LOGGER.info("No signing candidates found") |
| 232 | + return 0 |
| 233 | + |
| 234 | + fail_on_error = args.fail_on_lookup_error.lower() != "false" |
| 235 | + try: |
| 236 | + to_sign = filter_already_signed(all_items, pyxis_url, max_workers=args.max_workers) |
| 237 | + except Exception: |
| 238 | + if fail_on_error: |
| 239 | + raise |
| 240 | + LOGGER.warning( |
| 241 | + "Pyxis lookup failed; failOnSignatureLookupError=false." |
| 242 | + " Submitting all %d items without filtering.", |
| 243 | + len(all_items), |
| 244 | + ) |
| 245 | + to_sign = all_items |
| 246 | + |
| 247 | + LOGGER.info("Items to sign after filtering: %d", len(to_sign)) |
| 248 | + |
| 249 | + if not to_sign: |
| 250 | + LOGGER.info("All items already signed, nothing to submit") |
| 251 | + return 0 |
| 252 | + |
| 253 | + batches = batch_signing_items(to_sign, max_batch_bytes=args.batch_max_size) |
| 254 | + batch_dir = args.output if args.output else Path(tempfile.mkdtemp()) |
| 255 | + write_batches(batches, batch_dir) |
| 256 | + LOGGER.info("Wrote %d batch(es) to '%s'", len(batches), batch_dir) |
| 257 | + |
| 258 | + submit_config = get_submit_config(configmap, args, data_file) |
| 259 | + submit_batches(batch_dir, submit_config) |
| 260 | + |
| 261 | + return 0 |
| 262 | + |
| 263 | + |
| 264 | +if __name__ == "__main__": # pragma: no cover |
| 265 | + raise SystemExit(main()) |
0 commit comments