|
| 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 |
0 commit comments