Skip to content

Commit 5690d69

Browse files
committed
feat(ISV-7320): add direct index image signing script
Adds direct_sign_index_image.py for signing FBC index images via the container-signing pipeline, handling the logic from start to finish: ConfigMap reading, reference translation, Pyxis signature filtering, batching, and InternalRequest submission. Therefore, it's meant to be called as a standalone single command from a new managed Tekton task direct-sign-index-image with no other steps needed in bash. Imports shared utilities from rh_direct_sign_image.py for signing item model, Pyxis lookups, batching, and request submission. - Two functions specific to direct-sign-index-image: translate_reference, collect_fbc_signing_items - main() orchestrates args, ConfigMap, collection, filtering, batching, and submission via get_submit_config/submit_batches - Full unit test suite (25 tests) Assisted-by: Claude Opus 4.6 Signed-off-by: Jakub Durkac <jdurkac@redhat.com>
1 parent 6c8d606 commit 5690d69

2 files changed

Lines changed: 898 additions & 0 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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+
try:
214+
args = parser.parse_args()
215+
LOGGER.setLevel(logging.DEBUG if args.verbose else logging.INFO)
216+
217+
pyxis_url = PYXIS_INSTANCE_MAP[args.pyxis_server]
218+
LOGGER.info("Using Pyxis instance: %s", pyxis_url)
219+
220+
data_file = json.loads(args.data_file.read_text())
221+
config_map_name = data_file.get("sign", {}).get("configMapName", "signing-config-map")
222+
configmap = get_configmap(config_map_name)
223+
signing_keys = get_signing_keys(configmap)
224+
LOGGER.info("Signing keys: %s", signing_keys)
225+
226+
fbc_results = json.loads(args.fbc_results.read_text())
227+
228+
all_items = collect_fbc_signing_items(fbc_results, signing_keys)
229+
LOGGER.info("Total signing candidates: %d", len(all_items))
230+
231+
if not all_items:
232+
LOGGER.info("No signing candidates found")
233+
return 0
234+
235+
fail_on_error = args.fail_on_lookup_error.lower() != "false"
236+
try:
237+
to_sign = filter_already_signed(all_items, pyxis_url, max_workers=args.max_workers)
238+
except Exception:
239+
if fail_on_error:
240+
LOGGER.exception("Pyxis signature lookup failed")
241+
raise
242+
LOGGER.warning(
243+
"Pyxis lookup failed; failOnSignatureLookupError=false."
244+
" Submitting all %d items without filtering.",
245+
len(all_items),
246+
)
247+
to_sign = all_items
248+
249+
LOGGER.info("Items to sign after filtering: %d", len(to_sign))
250+
251+
if not to_sign:
252+
LOGGER.info("All items already signed, nothing to submit")
253+
return 0
254+
255+
batches = batch_signing_items(to_sign, max_batch_bytes=args.batch_max_size)
256+
batch_dir = args.output if args.output else Path(tempfile.mkdtemp())
257+
write_batches(batches, batch_dir)
258+
LOGGER.info("Wrote %d batch(es) to '%s'", len(batches), batch_dir)
259+
260+
submit_config = get_submit_config(configmap, args, data_file)
261+
submit_batches(batch_dir, submit_config)
262+
263+
except Exception as e:
264+
LOGGER.error("Fatal error during index image signing: %s", e, exc_info=True)
265+
return 1
266+
267+
return 0
268+
269+
270+
if __name__ == "__main__": # pragma: no cover
271+
raise SystemExit(main())

0 commit comments

Comments
 (0)