Skip to content

Commit 61ccf62

Browse files
committed
Add multi-OCP FBC support for LP assembly pipelines
- Extract shared FBC validation utilities into pyartcd/fbc_util.py (extract_fbc_labels, validate_fbc_related_images, extract_ocp_version_from_nvr) - Refactor release_from_fbc.py to delegate to shared fbc_util functions - gen_assembly_lp: validate FBC consistency when multiple pullspecs are provided, and store basis.fbc_pullspecs as a dict keyed by OCP version - prepare_release_lp: loop over OCP_TARGET_VERSIONS from group config, build FBC per version with --major-minor, and validate consistency before creating shipments - Add comprehensive tests for fbc_util, gen_assembly_lp, and prepare_release_lp multi-FBC scenarios Co-authored-by: Cursor <cursoragent@cursor.com> rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent 0c471d1 commit 61ccf62

7 files changed

Lines changed: 644 additions & 175 deletions

File tree

pyartcd/pyartcd/fbc_util.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""
2+
Shared utilities for FBC (File-Based Catalog) image validation.
3+
4+
Used by release_from_fbc, gen_assembly_lp, and prepare_release_lp pipelines
5+
to validate consistency of related images across FBC builds targeting
6+
different OCP versions.
7+
"""
8+
9+
import asyncio
10+
import json
11+
import logging
12+
import os
13+
import re
14+
from collections import defaultdict
15+
from typing import Dict, List, Optional
16+
17+
from artcommonlib import exectools
18+
from elliottlib.util import extract_nvrs_from_fbc
19+
from tenacity import retry, stop_after_attempt
20+
21+
logger = logging.getLogger(__name__)
22+
23+
_OCP_VERSION_RE = re.compile(r'\.ocp(\d+\.\d+)')
24+
25+
26+
def extract_ocp_version_from_nvr(nvr: str) -> Optional[str]:
27+
"""Extract the OCP target version from an LP FBC NVR.
28+
29+
LP FBC NVRs encode the target OCP version in the release field as
30+
``.ocp{major}.{minor}`` (e.g. ``operator-fbc-6.4.1-1234.ocp4.18``).
31+
Returns the version string (e.g. ``"4.18"``) or None if not found.
32+
"""
33+
match = _OCP_VERSION_RE.search(nvr)
34+
return match.group(1) if match else None
35+
36+
37+
async def extract_fbc_labels(fbc_pullspec: str) -> Dict[str, Optional[str]]:
38+
"""
39+
Extract the NVR and __doozer_key labels from an FBC image.
40+
41+
Returns a dict with 'nvr' and 'doozer_key' keys. On failure, returns
42+
None values rather than raising.
43+
"""
44+
logger.info("Extracting FBC labels from FBC pullspec: %s", fbc_pullspec)
45+
46+
@retry(reraise=True, stop=stop_after_attempt(3))
47+
async def _get_image_info():
48+
oc_cmd = ['oc', 'image', 'info', fbc_pullspec, '--filter-by-os', 'amd64', '-o', 'json']
49+
50+
quay_auth_file = os.getenv("QUAY_AUTH_FILE")
51+
if quay_auth_file:
52+
oc_cmd.extend(['--registry-config', quay_auth_file])
53+
54+
rc, stdout, stderr = await exectools.cmd_gather_async(oc_cmd, check=False)
55+
if rc != 0:
56+
raise RuntimeError(f"Failed to get image info for FBC {fbc_pullspec}: {stderr}")
57+
return stdout
58+
59+
try:
60+
image_info_output = await _get_image_info()
61+
image_info = json.loads(image_info_output)
62+
63+
labels = image_info.get('config', {}).get('config', {}).get('Labels', {})
64+
nvr = labels.get('com.redhat.art.nvr')
65+
doozer_key = labels.get('__doozer_key')
66+
67+
logger.info("Extracted FBC labels - nvr: %s, doozer_key: %s", nvr, doozer_key)
68+
return {'nvr': nvr, 'doozer_key': doozer_key}
69+
70+
except Exception as e:
71+
logger.exception("Failed to extract labels from FBC %s: %s", fbc_pullspec, e)
72+
return {'nvr': None, 'doozer_key': None}
73+
74+
75+
async def validate_fbc_related_images(fbc_pullspecs: List[str], product: str) -> List[str]:
76+
"""
77+
Validate that FBC pullspecs from the same operator have the same related images.
78+
79+
Different operators are allowed to have different related images, but
80+
multiple FBC builds for the *same* operator (targeting different OCP versions)
81+
must contain identical related-image NVR sets.
82+
83+
Returns the combined list of all unique related images across all operators.
84+
Raises RuntimeError if any same-operator FBCs have mismatched related images.
85+
"""
86+
logger.info("Validating related images for %d FBC builds (grouped by operator)...", len(fbc_pullspecs))
87+
88+
# Step 1: Extract operator identifiers from all FBCs in parallel
89+
logger.info("Extracting operator identifiers from FBC images...")
90+
label_tasks = [extract_fbc_labels(fbc) for fbc in fbc_pullspecs]
91+
fbc_labels_list = await asyncio.gather(*label_tasks)
92+
93+
# Step 2: Map FBCs to operators
94+
fbc_to_operator = {}
95+
for fbc_pullspec, labels in zip(fbc_pullspecs, fbc_labels_list):
96+
operator = labels.get('doozer_key')
97+
98+
if not operator:
99+
try:
100+
tag = fbc_pullspec.split(':')[-1]
101+
if '-fbc-' in tag:
102+
operator = tag.split('-fbc-')[0]
103+
logger.error(
104+
"Missing __doozer_key label for %s, using extracted operator name: %s",
105+
fbc_pullspec, operator,
106+
)
107+
else:
108+
operator = f"UNKNOWN-{hash(fbc_pullspec) & 0xFFFFFFFF:08x}"
109+
logger.error(
110+
"Missing __doozer_key label for %s, using generated operator identifier: %s",
111+
fbc_pullspec, operator,
112+
)
113+
except Exception as e:
114+
logger.error("Failed to extract operator from %s: %s", fbc_pullspec, e)
115+
operator = f"UNKNOWN-{hash(fbc_pullspec) & 0xFFFFFFFF:08x}"
116+
117+
fbc_to_operator[fbc_pullspec] = operator
118+
119+
# Step 3: Group FBCs by operator
120+
operator_groups = defaultdict(list)
121+
for fbc, operator in fbc_to_operator.items():
122+
operator_groups[operator].append(fbc)
123+
124+
logger.info("Found %d operator group(s):", len(operator_groups))
125+
for operator, fbcs in operator_groups.items():
126+
logger.info(" %s: %d FBC(s)", operator, len(fbcs))
127+
128+
# Step 4: Extract related images from each FBC sequentially to avoid temp directory conflicts
129+
logger.info("Extracting related images from all FBCs...")
130+
fbc_related_images = {}
131+
for fbc_pullspec in fbc_pullspecs:
132+
related_nvrs = await extract_nvrs_from_fbc(fbc_pullspec, product)
133+
fbc_related_images[fbc_pullspec] = sorted(related_nvrs)
134+
135+
# Step 5: Validate related images within each operator group
136+
all_related_images = set()
137+
validation_errors = []
138+
139+
for operator, fbcs_in_group in operator_groups.items():
140+
if len(fbcs_in_group) == 1:
141+
logger.info("Operator '%s': single FBC, no validation needed", operator)
142+
all_related_images.update(fbc_related_images[fbcs_in_group[0]])
143+
continue
144+
145+
logger.info("Validating %d FBCs for operator '%s'...", len(fbcs_in_group), operator)
146+
reference_fbc = fbcs_in_group[0]
147+
reference_images = fbc_related_images[reference_fbc]
148+
149+
mismatches = []
150+
for fbc_pullspec in fbcs_in_group[1:]:
151+
current_images = fbc_related_images[fbc_pullspec]
152+
if current_images != reference_images:
153+
only_in_reference = set(reference_images) - set(current_images)
154+
only_in_current = set(current_images) - set(reference_images)
155+
mismatches.append(
156+
{
157+
'fbc': fbc_pullspec,
158+
'only_in_reference': sorted(only_in_reference),
159+
'only_in_current': sorted(only_in_current),
160+
}
161+
)
162+
163+
if mismatches:
164+
error_msg = f"Operator '{operator}': FBC builds have mismatched related images.\n"
165+
error_msg += f"Reference FBC: {reference_fbc}\n"
166+
for mismatch in mismatches:
167+
error_msg += f"\nMismatch with: {mismatch['fbc']}\n"
168+
if mismatch['only_in_reference']:
169+
error_msg += f" Only in reference: {mismatch['only_in_reference']}\n"
170+
if mismatch['only_in_current']:
171+
error_msg += f" Only in current: {mismatch['only_in_current']}\n"
172+
validation_errors.append(error_msg)
173+
else:
174+
logger.info(
175+
"Operator '%s': all %d FBCs have matching related images (%d images)",
176+
operator, len(fbcs_in_group), len(reference_images),
177+
)
178+
all_related_images.update(reference_images)
179+
180+
# Step 6: Report validation results
181+
if validation_errors:
182+
full_error = "\n".join(validation_errors)
183+
logger.error("Validation failed:\n%s", full_error)
184+
raise RuntimeError(full_error)
185+
186+
unique_images = sorted(all_related_images)
187+
logger.info(
188+
"Validation passed: %d total unique related images across %d operator(s)",
189+
len(unique_images), len(operator_groups),
190+
)
191+
return unique_images

pyartcd/pyartcd/pipelines/gen_assembly_lp.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
from artcommonlib.github_auth import get_github_client_for_org
99
from artcommonlib.rpm_utils import parse_nvr
1010
from artcommonlib.util import merge_objects, new_roundtrip_yaml_handler, split_git_url
11-
from elliottlib.util import extract_nvrs_from_fbc
1211

1312
from pyartcd import constants
1413
from pyartcd.cli import cli, click_coroutine, pass_runtime
14+
from pyartcd.fbc_util import (
15+
extract_fbc_labels,
16+
extract_ocp_version_from_nvr,
17+
validate_fbc_related_images,
18+
)
1519
from pyartcd.git import GitRepository
1620
from pyartcd.runtime import Runtime
1721

@@ -108,11 +112,18 @@ def _categorize_nvrs(self, nvrs: List[str]) -> Dict[str, List[str]]:
108112
async def _extract_nvrs_from_fbc(self) -> Dict[str, str]:
109113
"""
110114
Extract operand NVRs from FBC pullspecs and return a dict of distgit_key -> NVR.
115+
116+
When multiple FBC pullspecs are provided (one per OCP target version),
117+
validates that all FBCs for the same operator contain identical related images.
111118
"""
112-
all_related_nvrs: List[str] = []
113-
for fbc_pullspec in self.fbc_pullspecs:
114-
related_nvrs = await extract_nvrs_from_fbc(fbc_pullspec, self.product)
115-
all_related_nvrs.extend(related_nvrs)
119+
if len(self.fbc_pullspecs) > 1:
120+
all_related_nvrs = await validate_fbc_related_images(self.fbc_pullspecs, self.product)
121+
else:
122+
from elliottlib.util import extract_nvrs_from_fbc as _extract
123+
all_related_nvrs = []
124+
for fbc_pullspec in self.fbc_pullspecs:
125+
related_nvrs = await _extract(fbc_pullspec, self.product)
126+
all_related_nvrs.extend(related_nvrs)
116127

117128
all_related_nvrs = sorted(set(all_related_nvrs))
118129
categorized = self._categorize_nvrs(all_related_nvrs)
@@ -203,7 +214,6 @@ async def _resolve_parent_operands(self) -> Dict[str, str]:
203214
)
204215

205216
releases_config = yaml.load(releases_yaml_path)
206-
releases_model = Model(dict_to_model=releases_config)
207217

208218
parent_assembly = releases_config.get('releases', {}).get(self.basis_assembly)
209219
if parent_assembly is None:
@@ -226,18 +236,46 @@ async def _resolve_parent_operands(self) -> Dict[str, str]:
226236
)
227237
return operand_map
228238

239+
async def _build_fbc_pullspecs_map(self) -> Dict[str, str]:
240+
"""Map each FBC pullspec to its OCP target version.
241+
242+
Extracts the OCP version from the NVR embedded in the FBC image labels.
243+
Returns a dict of ``{"4.18": "quay.io/...", "4.19": "quay.io/...", ...}``.
244+
If only a single pullspec is provided or the OCP version cannot be determined,
245+
falls back to a flat list representation.
246+
"""
247+
fbc_map: Dict[str, str] = {}
248+
for pullspec in self.fbc_pullspecs:
249+
labels = await extract_fbc_labels(pullspec)
250+
nvr = labels.get('nvr')
251+
ocp_version = extract_ocp_version_from_nvr(nvr) if nvr else None
252+
if ocp_version:
253+
fbc_map[ocp_version] = pullspec
254+
else:
255+
self._logger.warning(
256+
"Could not determine OCP target version from FBC %s (nvr=%s); "
257+
"falling back to flat list",
258+
pullspec, nvr,
259+
)
260+
return {}
261+
return fbc_map
262+
229263
def _generate_assembly_definition(
230264
self,
231265
operand_map: Dict[str, str],
232266
parent_operand_map: Optional[Dict[str, str]] = None,
267+
fbc_pullspecs_map: Optional[Dict[str, str]] = None,
233268
) -> OrderedDict:
234269
"""Generate the assembly YAML definition."""
235270

236271
basis: Dict = {}
237272
if self.basis_assembly:
238273
basis['assembly'] = self.basis_assembly
239274
if self.fbc_pullspecs:
240-
basis['fbc_pullspecs'] = list(self.fbc_pullspecs)
275+
if fbc_pullspecs_map:
276+
basis['fbc_pullspecs'] = dict(fbc_pullspecs_map)
277+
else:
278+
basis['fbc_pullspecs'] = list(self.fbc_pullspecs)
241279

242280
group_info: Dict = {
243281
'shipment': {
@@ -375,7 +413,13 @@ async def run(self) -> OrderedDict:
375413
for key, nvr in sorted(operand_map.items()):
376414
self._logger.info(" %s = %s", key, nvr)
377415

378-
assembly_definition = self._generate_assembly_definition(operand_map, parent_operand_map)
416+
fbc_pullspecs_map: Optional[Dict[str, str]] = None
417+
if self.fbc_pullspecs:
418+
fbc_pullspecs_map = await self._build_fbc_pullspecs_map()
419+
420+
assembly_definition = self._generate_assembly_definition(
421+
operand_map, parent_operand_map, fbc_pullspecs_map,
422+
)
379423

380424
if self.runtime.dry_run:
381425
from io import StringIO
@@ -408,7 +452,9 @@ async def run(self) -> OrderedDict:
408452
metavar="FBC_PULLSPECS",
409453
default="",
410454
help="Comma-separated FBC image pullspecs to extract operand NVRs from. "
411-
"Required unless --basis-assembly is provided.",
455+
"Provide one pullspec per OCP target version for multi-OCP products. "
456+
"When multiple are provided, related images are validated for consistency "
457+
"across OCP versions. Required unless --basis-assembly is provided.",
412458
)
413459
@click.option(
414460
"--basis-assembly",

0 commit comments

Comments
 (0)