diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 96790cdb..f5b2445e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -82,7 +82,7 @@ "name": "microshift-release", "source": "./plugins/microshift-release", "description": "Set of tools to perform MicroShift Release Testing activities", - "version": "1.1.0" + "version": "1.3.0" }, { "name": "pr-review", diff --git a/plugins/microshift-release/.claude-plugin/plugin.json b/plugins/microshift-release/.claude-plugin/plugin.json index c89364b3..a913452a 100644 --- a/plugins/microshift-release/.claude-plugin/plugin.json +++ b/plugins/microshift-release/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "microshift-release", "description": "Set of tools to perform MicroShift Release Testing activities", - "version": "1.2.0", + "version": "1.3.0", "author": { "name": "agullon" }, diff --git a/plugins/microshift-release/scripts/advisory_promotion.py b/plugins/microshift-release/scripts/advisory_promotion.py new file mode 100644 index 00000000..f3958e26 --- /dev/null +++ b/plugins/microshift-release/scripts/advisory_promotion.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +"""Validate Konflux bootc advisory promotion readiness — Phase 3. + +QE sign-off checks for bootc image advisories before shipping. +Every check is atomic and split per image variant (arch + RHEL version). + +Usage: advisory_promotion.py [--verbose] [--json] +""" + +import argparse +import json +import logging +import re +import sys +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed + +from lib import artifacts, brew, pyxis +from validate_artifacts import ( + classify_version, _minor_tuple, _pass, _fail, _warn, _skip, + _STATUS_EMOJI as _BASE_STATUS_EMOJI, _BOOTC_MIN_MINOR, +) + +_STATUS_EMOJI = {**_BASE_STATUS_EMOJI, "SKIP": "⏭️"} + +logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + +_ARCHES = ["amd64", "arm64"] + +_EXPECTED_REPO_TEMPLATE = "registry.stage.redhat.io/openshift{major}/microshift-bootc-rhel{rhel}" + +_PER_VARIANT_CHECKS = [ + "advisory_image_present", + "advisory_repository", + "advisory_image_sha", + "catalog_stage_present", + "catalog_stage_tag_commit", + "catalog_stage_tag_date", + "catalog_stage_no_xy0_tag", + "catalog_stage_chi", + "catalog_prod_present", + "catalog_prod_tag_commit", + "catalog_prod_tag_date", + "catalog_prod_no_xy0_tag", + "catalog_prod_chi", +] + +_GLOBAL_CHECKS = [ + "advisory_type", + "shipment_type", + "shipment_filename", + "shipment_nvr_commit", + "advisory_sha_distinct_el9", + "advisory_sha_distinct_el10", + "shipment_mr_approved", +] + + +def _rhel_versions(version_info): + """Return RHEL versions to check based on the MicroShift version.""" + minor = _minor_tuple(version_info["minor"]) + z = version_info["z"] + if minor > (4, 22) or (minor == (4, 22) and z >= 2): + return [9, 10] + return [9] + + +def _variants(version_info): + """Return list of (arch, rhel) tuples for this version.""" + return [(arch, rhel) for arch in _ARCHES for rhel in _rhel_versions(version_info)] + + +def _variant_key(arch, rhel): + """Check ID prefix for an image variant, e.g. 'amd64_el9'.""" + return f"{arch}_el{rhel}" + + +def _all_check_ids(version_info): + rhel_vers = _rhel_versions(version_info) + ids = [f"{_variant_key(arch, rhel)}_{suffix}" + for arch, rhel in _variants(version_info) + for suffix in _PER_VARIANT_CHECKS] + for gc in _GLOBAL_CHECKS: + rhel_match = re.search(r"el(\d+)", gc) + if rhel_match and int(rhel_match.group(1)) not in rhel_vers: + continue + ids.append(gc) + return ids + + +def _expected_repo(rhel, major=4): + return _EXPECTED_REPO_TEMPLATE.format(major=major, rhel=rhel) + + +def _get_advisory_image(advisory_details, arch, rhel): + """Get the advisory image entry for a specific arch/rhel, or None.""" + if advisory_details is None or not advisory_details.get("images"): + return None + target_key = f"{arch}/el{rhel}" + for img in advisory_details["images"]: + if img.get("arch_key") == target_key: + return img + # Fallback: match on architecture alone (for advisory YAMLs without RHEL in component) + if rhel == 9: + for img in advisory_details["images"]: + if img.get("architecture") == arch and "el" not in img.get("arch_key", ""): + return img + return None + + +def _get_assembly_tag(catalog_result): + """Extract the assembly tag and image metadata from a catalog result.""" + image_meta = catalog_result.get("image") if catalog_result else None + if image_meta is None: + return None, None + for t in image_meta.get("tags", []): + if "assembly" in t.get("name", ""): + return t["name"], image_meta + return None, image_meta + + +# ── Per-variant checks ─────────────────────────────────────────── + + +def check_in_advisory(vk, arch, rhel, advisory_details): + """Image variant is present in advisory spec.content.images.""" + check_id = f"{vk}_advisory_image_present" + img = _get_advisory_image(advisory_details, arch, rhel) + if img is None: + if advisory_details is None or not advisory_details.get("images"): + return _fail(check_id, "Could not fetch or parse advisory YAML") + return _fail(check_id, f"{arch}/el{rhel} not found in advisory") + return _pass(check_id, f"{arch}/el{rhel} present", + [f"Component: {img.get('component', '?')}"]) + + +def check_repository(vk, arch, rhel, major, advisory_details): + """Image references the correct stage registry repository.""" + check_id = f"{vk}_advisory_repository" + img = _get_advisory_image(advisory_details, arch, rhel) + if img is None: + return _warn(check_id, f"No {arch}/el{rhel} image in advisory") + repo = img.get("repo", "") + expected = _expected_repo(rhel, major) + if repo == expected: + return _pass(check_id, expected) + return _fail(check_id, f"Wrong repository: {repo}", + [f"Expected: {expected}", f"Got: {repo}"]) + + +def check_image_sha(vk, arch, rhel, advisory_details): + """Advisory contains a non-empty image SHA for this variant.""" + check_id = f"{vk}_advisory_image_sha" + img = _get_advisory_image(advisory_details, arch, rhel) + if img is None: + return _warn(check_id, f"No {arch}/el{rhel} image in advisory") + sha = img.get("sha") + if not sha: + return _fail(check_id, f"No SHA found for {arch}/el{rhel}") + return _pass(check_id, f"sha256:{sha[:12]}", + [f"Full: sha256:{sha}"]) + + +def check_in_catalog(vk, catalog_env, catalog_result, version_info, phase="stage"): + """Image is published in the specified catalog (uses prefetched result).""" + check_id = f"{vk}_catalog_{catalog_env}_present" + if catalog_env == "prod" and phase == "stage": + return _skip(check_id, "N/A (stage mode)") + if catalog_env == "prod" and version_info["type"] in ("EC", "RC"): + return _skip(check_id, f"N/A ({version_info['type']} not shipped to prod)") + if catalog_result.get("valid"): + return _pass(check_id, f"Found in {catalog_env} catalog") + return _fail(check_id, f"Not found in {catalog_env} catalog", + [catalog_result.get("reason", "")]) + + +def _catalog_not_fetched(catalog_result): + """True when catalog data was not fetched (empty dict from stage-only mode).""" + return not catalog_result or (not catalog_result.get("valid") and not catalog_result.get("reason")) + + +def check_tag_commit_id(vk, catalog_env, catalog_result): + """Assembly tag commit hash matches the catalog image's source commit.""" + check_id = f"{vk}_catalog_{catalog_env}_tag_commit" + if _catalog_not_fetched(catalog_result): + return _skip(check_id, f"N/A ({catalog_env} not queried)") + assembly_tag, image_meta = _get_assembly_tag(catalog_result) + if image_meta is None: + return _warn(check_id, "Catalog image metadata unavailable") + if assembly_tag is None: + return _fail(check_id, "No assembly tag found on catalog image") + + commit_match = re.search(r"\.g([0-9a-f]+)\.", assembly_tag) + if not commit_match: + return _fail(check_id, "No commit hash in assembly tag", + [f"Tag: {assembly_tag}"]) + + tag_commit = commit_match.group(1) + catalog_commit = image_meta.get("commit_id") or image_meta.get("commit_short") + if not catalog_commit: + return _warn(check_id, f"Commit {tag_commit} in tag, no catalog metadata to compare", + [f"Tag: {assembly_tag}"]) + + if catalog_commit.startswith(tag_commit) or tag_commit.startswith(catalog_commit): + return _pass(check_id, f"Commit {tag_commit} matches catalog", + [f"Tag: {assembly_tag}", f"Catalog: {catalog_commit[:12]}"]) + return _fail(check_id, f"Commit mismatch: tag={tag_commit} catalog={catalog_commit[:12]}", + [f"Tag: {assembly_tag}"]) + + +def check_tag_build_date(vk, catalog_env, catalog_result): + """Assembly tag contains a valid build date timestamp.""" + check_id = f"{vk}_catalog_{catalog_env}_tag_date" + if _catalog_not_fetched(catalog_result): + return _skip(check_id, f"N/A ({catalog_env} not queried)") + assembly_tag, image_meta = _get_assembly_tag(catalog_result) + if image_meta is None: + return _warn(check_id, "Catalog image metadata unavailable") + if assembly_tag is None: + return _fail(check_id, "No assembly tag found on catalog image") + + date_match = re.search(r"v[\d.]+-(\d{12})\.", assembly_tag) + if not date_match: + return _fail(check_id, "No build date in assembly tag", + [f"Tag: {assembly_tag}"]) + + ts = date_match.group(1) + year, month, day = int(ts[:4]), int(ts[4:6]), int(ts[6:8]) + hour, minute = int(ts[8:10]), int(ts[10:12]) + if not (2020 <= year <= 2099 and 1 <= month <= 12 and 1 <= day <= 31 + and 0 <= hour <= 23 and 0 <= minute <= 59): + return _fail(check_id, f"Invalid timestamp {ts}", + [f"Tag: {assembly_tag}"]) + + formatted = f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[8:10]}:{ts[10:12]}" + return _pass(check_id, formatted, [f"Tag: {assembly_tag}"]) + + +def check_no_xy0_tag(vk, catalog_env, catalog_result, version_info): + """For z-streams, verify no X.Y.0 assembly tag on this variant's image.""" + check_id = f"{vk}_catalog_{catalog_env}_no_xy0_tag" + if _catalog_not_fetched(catalog_result): + return _skip(check_id, f"N/A ({catalog_env} not queried)") + if version_info["type"] != "Z": + return _skip(check_id, f"N/A ({version_info['type']}, not z-stream)") + if version_info["z"] == 0: + return _skip(check_id, "N/A (X.Y.0, not z-stream)") + + _, image_meta = _get_assembly_tag(catalog_result) + if image_meta is None: + return _warn(check_id, "Catalog image metadata unavailable") + + tags = image_meta.get("tags", []) + minor = version_info["minor"] + xy0_pattern = re.compile(rf"assembly\.{re.escape(minor)}\.0\b") + xy0_tags = [t.get("name", "") for t in tags if xy0_pattern.search(t.get("name", ""))] + if xy0_tags: + return _fail(check_id, f"Found {minor}.0 tag on image", + [f"Tags: {', '.join(xy0_tags)}"]) + return _pass(check_id, f"No {minor}.0 tags ({len(tags)} checked)") + + +def check_chi_freshness(vk, catalog_env, catalog_result): + """Container Health Index grade is acceptable for promotion.""" + check_id = f"{vk}_catalog_{catalog_env}_chi" + if _catalog_not_fetched(catalog_result): + return _skip(check_id, f"N/A ({catalog_env} not queried)") + if not catalog_result or not catalog_result.get("image"): + return _warn(check_id, "Catalog image metadata unavailable") + + image_meta = catalog_result["image"] + grade = image_meta.get("freshness_grade") + if not grade: + return _warn(check_id, "No CHI grade available") + + if grade == "A": + return _pass(check_id, f"CHI grade {grade}") + return _fail(check_id, f"CHI grade {grade} (expected A)", + ["Container health has degraded — review before promotion"]) + + +# ── Global checks ──────────────────────────────────────────────── + + +def check_advisory_type(advisory_details, version_info): + """Advisory YAML spec.type matches expected type for this release.""" + check_id = "advisory_type" + if advisory_details is None: + return _warn(check_id, "Advisory data unavailable") + + spec_type = advisory_details.get("spec_type") + if not spec_type: + return _fail(check_id, "No spec.type found in advisory YAML") + + expected = _expected_types(version_info) + if spec_type in expected: + return _pass(check_id, f"spec.type = {spec_type}", + [f"Expected: {' or '.join(expected)} for {version_info['type']}"]) + return _fail(check_id, + f"spec.type = {spec_type}, expected {' or '.join(expected)}", + [f"Got: {spec_type}", f"Expected: {' or '.join(expected)}"]) + + +def check_shipment_filename(shipment, version_info): + """Shipment YAML filename matches expected path pattern.""" + check_id = "shipment_filename" + if shipment.get("skipped") or not shipment.get("found"): + return _warn(check_id, "Shipment MR unavailable") + + yaml_file = shipment.get("yaml_file") + if not yaml_file: + return _fail(check_id, "No YAML file found in shipment MR") + + version = version_info["version"] + minor = version_info["minor"] + minor_dash = minor.replace(".", "-") + + expected_prefix = f"shipment/ocp/openshift-{minor}/openshift-{minor_dash}/" + if not yaml_file.startswith(expected_prefix): + return _fail(check_id, f"Unexpected path: {yaml_file}", + [f"Expected prefix: {expected_prefix}"]) + + if "microshift-bootc" not in yaml_file: + return _fail(check_id, f"Filename missing 'microshift-bootc': {yaml_file}") + + base_version = re.sub(r"-(ec|rc)\.\d+$", "", version) + if f"/{base_version}." not in yaml_file and f"/{version}." not in yaml_file: + return _fail(check_id, f"Filename missing version {version}: {yaml_file}") + + return _pass(check_id, yaml_file) + + +def check_shipment_nvr_commit(shipment, version_info): + """Shipment NVR commit matches the Brew RPM build commit.""" + check_id = "shipment_nvr_commit" + if shipment.get("skipped") or not shipment.get("found"): + return _warn(check_id, "Shipment MR unavailable") + + content = shipment.get("yaml_content") + if not content: + return _warn(check_id, "No shipment YAML content") + + ship = content.get("shipment", content) + nvrs = ship.get("snapshot", {}).get("nvrs", []) + if not nvrs: + return _warn(check_id, "No NVRs in shipment snapshot") + + shipment_commit = None + shipment_nvr = None + for nvr in nvrs: + m = re.search(r"\.g([0-9a-f]+)\.", nvr) + if m: + shipment_commit = m.group(1) + shipment_nvr = nvr + break + + if not shipment_commit: + return _fail(check_id, "No commit hash found in shipment NVRs", + [f"NVRs: {', '.join(nvrs)}"]) + + vpn_ok = brew.check_vpn() + if not vpn_ok: + return _warn(check_id, + f"Shipment NVR commit {shipment_commit}, VPN required for Brew comparison", + [f"Shipment NVR: {shipment_nvr}"]) + + vtype = version_info["type"] + brew_type = vtype if vtype in ("RC", "EC", "XY") else "Z" + build_info = brew.get_build_info(version_info["version"], brew_type) + if not build_info.get("found"): + return _warn(check_id, + f"Shipment NVR commit {shipment_commit}, Brew build not found", + [f"Shipment NVR: {shipment_nvr}"]) + + brew_commit = build_info.get("commit") + if not brew_commit: + return _warn(check_id, "No commit in Brew build NVR", + [f"Brew NVR: {build_info.get('nvr')}"]) + + if shipment_commit == brew_commit: + return _pass(check_id, + f"Commit {shipment_commit} matches Brew", + [f"Shipment NVR: {shipment_nvr}", + f"Brew NVR: {build_info['nvr']}"]) + + return _fail(check_id, + f"Commit mismatch: shipment={shipment_commit} brew={brew_commit}", + [f"Shipment NVR: {shipment_nvr}", + f"Brew NVR: {build_info['nvr']}"]) + + +def _expected_types(version_info): + if version_info["type"] == "XY": + return ["RHEA"] + return ["RHBA", "RHSA"] + + +def check_shipment_type(shipment, version_info): + """Shipment YAML releaseNotes.type matches expected advisory type.""" + check_id = "shipment_type" + if shipment.get("skipped") or not shipment.get("found"): + return _warn(check_id, "Shipment MR unavailable") + + rn_type = shipment.get("release_notes_type") + if not rn_type: + return _warn(check_id, "No releaseNotes.type in shipment YAML") + + expected = _expected_types(version_info) + if rn_type in expected: + return _pass(check_id, f"releaseNotes.type = {rn_type}", + [f"Expected: {' or '.join(expected)} for {version_info['type']}"]) + return _fail(check_id, + f"releaseNotes.type = {rn_type}, expected {' or '.join(expected)}", + [f"Got: {rn_type}", f"Expected: {' or '.join(expected)}"]) + + +def check_image_sha_distinct(rhel, advisory_details): + """amd64 and arm64 advisory SHAs are different for this RHEL version.""" + check_id = f"advisory_sha_distinct_el{rhel}" + amd_img = _get_advisory_image(advisory_details, "amd64", rhel) + arm_img = _get_advisory_image(advisory_details, "arm64", rhel) + if amd_img is None or arm_img is None: + return _warn(check_id, "Cannot compare — missing arch in advisory") + + amd_sha = amd_img.get("sha") + arm_sha = arm_img.get("sha") + if not amd_sha or not arm_sha: + return _warn(check_id, "Cannot compare — missing SHA") + + if amd_sha == arm_sha: + return _fail(check_id, + f"amd64 and arm64 el{rhel} have identical SHAs", + [f"amd64: {amd_sha[:12]}", f"arm64: {arm_sha[:12]}"]) + return _pass(check_id, "SHAs are distinct", + [f"amd64: {amd_sha[:12]}", f"arm64: {arm_sha[:12]}"]) + + +def check_shipment_mr_approved(shipment): + """Shipment MR in ocp-shipment-data is approved.""" + check_id = "shipment_mr_approved" + if shipment.get("skipped"): + return _warn(check_id, shipment.get("reason", "Shipment MR check skipped")) + if not shipment.get("found"): + return _fail(check_id, shipment.get("reason", "Shipment MR not found")) + + mr_iid = shipment.get("mr_iid") + if mr_iid is None: + return _warn(check_id, "Shipment MR found but missing merge request ID") + project_id = artifacts._get_gitlab_project_id() + if project_id is None: + return _warn(check_id, "Cannot access GitLab project for approval check") + + approvals = artifacts.fetch_shipment_mr_approvals(project_id, mr_iid) + if approvals is None: + return _warn(check_id, f"Could not fetch approval status for MR !{mr_iid}") + + if approvals["approved"]: + approvers = ", ".join(approvals["approvers"]) or "unknown" + return _pass(check_id, f"MR !{mr_iid} approved by {approvers}", + [f"Approvals required: {approvals['approvals_required']}"]) + return _fail(check_id, f"MR !{mr_iid} not yet approved", + [f"Approvals required: {approvals['approvals_required']}", + f"Current approvers: {', '.join(approvals['approvers']) or 'none'}"]) + + +# ── Orchestrator ───────────────────────────────────────────────── + + +def run_advisory_promotion_checks(version_info, phase="stage"): + """Run all advisory promotion checks and return results in canonical order.""" + all_ids = _all_check_ids(version_info) + minor = version_info["minor"] + if _minor_tuple(minor) < _BOOTC_MIN_MINOR: + return [_skip(c, f"N/A (requires 4.18+, version is {minor})") for c in all_ids] + if version_info["type"] == "nightly": + return [_skip(c, "N/A (nightly builds have no advisories)") for c in all_ids] + + version = version_info["version"] + variants = _variants(version_info) + rhel_versions = _rhel_versions(version_info) + + # Fetch shared data + logger.info("Fetching shipment MR for %s...", version) + shipment = artifacts.fetch_shipment_mr(version) + + advisory_details = None + advisory_url = shipment.get("stage_advisory_url") if shipment.get("found") else None + if advisory_url: + logger.info("Fetching advisory YAML...") + advisory_details = artifacts.fetch_advisory_details(advisory_url) + + # Fetch catalog data per variant + catalog_envs = ("stage", "prod") if phase == "prod" else ("stage",) + logger.info("Querying catalog (%s)...", "/".join(catalog_envs)) + catalog = {} + with ThreadPoolExecutor(max_workers=8) as ex: + cat_futures = {} + for arch, rhel in variants: + for env in catalog_envs: + key = (arch, rhel, env) + cat_futures[ex.submit( + pyxis.check_catalog_image_graphql, version, env, arch=arch, rhel=rhel + )] = key + for future in as_completed(cat_futures): + key = cat_futures[future] + try: + catalog[key] = future.result() + except Exception as exc: + logger.exception("Catalog fetch failed for %s", key) + catalog[key] = {"valid": False, "reason": str(exc), + "image": None, "catalog": key[2]} + + # Run all checks + major = int(version_info["minor"].split(".")[0]) + with ThreadPoolExecutor(max_workers=8) as ex: + futures = {} + for arch, rhel in variants: + vk = _variant_key(arch, rhel) + stage_cat = catalog.get((arch, rhel, "stage"), {}) + prod_cat = catalog.get((arch, rhel, "prod"), {}) + futures[ex.submit(check_in_advisory, vk, arch, rhel, advisory_details)] = \ + f"{vk}_advisory_image_present" + futures[ex.submit(check_repository, vk, arch, rhel, major, advisory_details)] = \ + f"{vk}_advisory_repository" + futures[ex.submit(check_image_sha, vk, arch, rhel, advisory_details)] = \ + f"{vk}_advisory_image_sha" + for env, cat_data in (("stage", stage_cat), ("prod", prod_cat)): + futures[ex.submit(check_in_catalog, vk, env, cat_data, version_info, phase)] = \ + f"{vk}_catalog_{env}_present" + futures[ex.submit(check_tag_commit_id, vk, env, cat_data)] = \ + f"{vk}_catalog_{env}_tag_commit" + futures[ex.submit(check_tag_build_date, vk, env, cat_data)] = \ + f"{vk}_catalog_{env}_tag_date" + futures[ex.submit(check_no_xy0_tag, vk, env, cat_data, version_info)] = \ + f"{vk}_catalog_{env}_no_xy0_tag" + futures[ex.submit(check_chi_freshness, vk, env, cat_data)] = \ + f"{vk}_catalog_{env}_chi" + + + futures[ex.submit(check_advisory_type, advisory_details, version_info)] = \ + "advisory_type" + futures[ex.submit(check_shipment_type, shipment, version_info)] = \ + "shipment_type" + futures[ex.submit(check_shipment_filename, shipment, version_info)] = \ + "shipment_filename" + futures[ex.submit(check_shipment_nvr_commit, shipment, version_info)] = \ + "shipment_nvr_commit" + for rhel in rhel_versions: + futures[ex.submit(check_image_sha_distinct, rhel, advisory_details)] = \ + f"advisory_sha_distinct_el{rhel}" + futures[ex.submit(check_shipment_mr_approved, shipment)] = \ + "shipment_mr_approved" + + results = {} + for future in as_completed(futures): + check_id = futures[future] + try: + results[check_id] = future.result() + except Exception as exc: + logger.exception("Check %s raised unexpected error", check_id) + results[check_id] = _fail(check_id, f"Unexpected error: {exc}") + + return [results[c] for c in all_ids if c in results] + + +# ── Formatting ─────────────────────────────────────────────────── + + +def _section_line(title): + return f"── {title} " + "─" * max(1, 60 - len(title) - 4) + + +def _section_key(check_id, variant_keys): + """Determine which section a check belongs to.""" + for vk in variant_keys: + if check_id.startswith(vk + "_"): + return vk + return "Global" + + +def _group_by_section(results, version_info): + """Group results by variant section, returning (variant_keys, by_section).""" + variant_keys = [_variant_key(a, r) for a, r in _variants(version_info)] + by_section = {} + for r in results: + section = _section_key(r["check"], variant_keys) + by_section.setdefault(section, []).append(r) + return variant_keys, by_section + + +def format_text_short(version, results, version_info): + """Format checks grouped by variant section.""" + max_id_len = max((len(r["check"]) for r in results), default=20) + + # Status emojis render as 2 terminal columns (wide glyph) but len() returns 1 + _ICON_DISPLAY_WIDTH = 2 + + def _fmt_line(r): + icon = _STATUS_EMOJI.get(r["status"], r["status"]) + cid = r["check"].ljust(max_id_len) + lines = [f"{icon} {cid} {r['reason']}"] + if r["status"] == "FAIL" and r.get("details"): + pad = " " * (_ICON_DISPLAY_WIDTH + 2 + max_id_len + 2) + for d in r["details"]: + lines.append(f"{pad}{d}") + return lines + + variant_keys, by_section = _group_by_section(results, version_info) + + output = [f"Advisory Promotion: {version}", ""] + for section in [*variant_keys, "Global"]: + section_results = by_section.get(section, []) + if not section_results: + continue + output.append(_section_line(section)) + for r in section_results: + output.extend(_fmt_line(r)) + output.append("") + + return "\n".join(output) + + +def format_text_full(version, results, version_info): + """Format a detailed markdown report grouped by variant.""" + lines = [f"# Advisory Promotion: {version} ({version_info['type']})", ""] + + variant_keys, by_section = _group_by_section(results, version_info) + + for section in [*variant_keys, "Global"]: + section_results = by_section.get(section, []) + if not section_results: + continue + lines += [ + f"## {section}", "", + "| Status | Check | Details |", + "|--------|-------|---------|", + ] + for r in section_results: + detail = "; ".join(r.get("details", [])) or r["reason"] + icon = _STATUS_EMOJI.get(r["status"], r["status"]) + lines.append(f"| {icon} | `{r['check']}` | {detail} |") + lines.append("") + + counts = Counter(r["status"] for r in results) + summary_parts = [f"{v} {k}" for k, v in sorted(counts.items())] + lines.append(f"**Summary:** {', '.join(summary_parts)}") + return "\n".join(lines) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Validate Konflux bootc advisory promotion (Phase 3)" + ) + parser.add_argument("version", + help="Version string, e.g., 4.18.3, 4.19.0") + parser.add_argument("--prod", action="store_true", + help="Check both stage and prod catalogs (default: stage only)") + parser.add_argument("--verbose", action="store_true", + help="Show detailed markdown report") + parser.add_argument("--json", dest="json_output", action="store_true", + help="Output raw JSON") + return parser.parse_args() + + +def main(): + args = parse_args() + version_info = classify_version(args.version) + if version_info is None: + print(f"ERROR: Could not parse version string: {args.version!r}", + file=sys.stderr) + print("Expected formats: 4.18.3 | 4.19.0 | 4.19.0-ec.5 | 4.19.0-rc.2", + file=sys.stderr) + sys.exit(1) + + if _minor_tuple(version_info["minor"]) < _BOOTC_MIN_MINOR: + print(f"ERROR: Advisory promotion checks require version 4.18+, " + f"got {version_info['minor']}", file=sys.stderr) + sys.exit(1) + + logger.info("Checking advisory promotion for %s (%s)...", + args.version, version_info["type"]) + + phase = "prod" if args.prod else "stage" + results = run_advisory_promotion_checks(version_info, phase=phase) + + if args.json_output: + output = { + "version": args.version, + "type": version_info["type"], + "minor": version_info["minor"], + "advisory_checks": results, + } + print(json.dumps(output, indent=2)) + return + + if args.verbose: + print(format_text_full(args.version, results, version_info)) + else: + print(format_text_short(args.version, results, version_info)) + + if any(r["status"] == "FAIL" for r in results): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/microshift-release/scripts/advisory_promotion.sh b/plugins/microshift-release/scripts/advisory_promotion.sh new file mode 100644 index 00000000..d0bd2f58 --- /dev/null +++ b/plugins/microshift-release/scripts/advisory_promotion.sh @@ -0,0 +1,22 @@ +#!/usr/bin/bash + +set -euo pipefail + +SCRIPTDIR="$(dirname "${BASH_SOURCE[0]}")" +REPOROOT="$(git rev-parse --show-toplevel)" +OUTPUT_DIR="${REPOROOT}/_output" +ENVDIR="${OUTPUT_DIR}/release_testing" + +if [[ ! -d "${ENVDIR}" ]]; then + echo "Setting up required tools..." >&2 + mkdir -p "${OUTPUT_DIR}" + python3 -m venv "${ENVDIR}" +fi + +MARKER="${ENVDIR}/.deps-installed" +if [[ ! -f "${MARKER}" ]] || [[ "${SCRIPTDIR}/requirements.txt" -nt "${MARKER}" ]]; then + "${ENVDIR}/bin/python3" -m pip install -q -r "${SCRIPTDIR}/requirements.txt" >&2 + touch "${MARKER}" +fi + +"${ENVDIR}/bin/python3" "${SCRIPTDIR}/advisory_promotion.py" "$@" diff --git a/plugins/microshift-release/scripts/lib/artifacts.py b/plugins/microshift-release/scripts/lib/artifacts.py index f929c578..ee92378e 100644 --- a/plugins/microshift-release/scripts/lib/artifacts.py +++ b/plugins/microshift-release/scripts/lib/artifacts.py @@ -243,44 +243,29 @@ def validate_bootc_mirror(version, release_type, rhel_versions=None): def _fetch_advisory_data(advisory_url): """Fetch advisory YAML and extract spec.type and per-arch image SHAs. + Delegates to fetch_advisory_details and projects the result into the + flat {arch_key: sha} format expected by callers. + Args: advisory_url: GitLab raw URL to the advisory YAML. Returns: dict: {"spec_type": str|None, "images": {arch: sha}} or None on failure. """ - import yaml as _yaml # noqa: PLC0415 - try: - resp = _http_get(advisory_url, verify=False, timeout=15) - if resp.status_code != 200: - logger.debug("Advisory YAML fetch returned HTTP %d: %s", - resp.status_code, advisory_url) - return None - content = _yaml.safe_load(resp.text) - except (requests.RequestException, _yaml.YAMLError) as exc: - logger.debug("Advisory YAML fetch/parse failed for %s: %s", - advisory_url, exc) + details = fetch_advisory_details(advisory_url) + if details is None: return None - spec = content.get("spec", {}) - images = spec.get("content", {}).get("images", []) image_shas = {} - for img in images: - comp = img.get("component", "") - if "microshift-bootc" not in comp: - continue - arch = img.get("architecture") - sha_match = re.search(r"@sha256:([0-9a-f]+)", - img.get("containerImage", "")) - if arch and sha_match: - rhel_match = re.search(r"rhel(\d+)", comp) - if rhel_match: - image_shas[f"{arch}/el{rhel_match.group(1)}"] = sha_match.group(1) - else: - image_shas[arch] = sha_match.group(1) + if details.get("images"): + for img in details["images"]: + key = img.get("arch_key", img.get("architecture", "")) + sha = img.get("sha") + if key and sha: + image_shas[key] = sha return { - "spec_type": spec.get("type"), + "spec_type": details.get("spec_type"), "images": image_shas if image_shas else None, } @@ -695,3 +680,79 @@ def _parse_spec_content(content): names.append(f"{base_name}-{args[0]}") return list(dict.fromkeys(names)) if names else None + + +def fetch_advisory_details(advisory_url): + """Fetch advisory YAML and return full image details for promotion checks. + + Returns: + dict: {"spec_type": str, "images": [{"component", "architecture", + "containerImage", "sha", "repo"}]} or None on failure. + """ + import yaml as _yaml # noqa: PLC0415 + try: + resp = _http_get(advisory_url, verify=False, timeout=15) + if resp.status_code != 200: + logger.warning("Advisory YAML fetch returned HTTP %d: %s", + resp.status_code, advisory_url) + return None + content = _yaml.safe_load(resp.text) + except (requests.RequestException, _yaml.YAMLError) as exc: + logger.warning("Advisory YAML fetch/parse failed for %s: %s", + advisory_url, exc) + return None + + if not isinstance(content, dict): + logger.warning("Advisory YAML is not a mapping: %s", advisory_url) + return None + spec = content.get("spec", {}) + raw_images = spec.get("content", {}).get("images", []) + images = [] + for img in raw_images: + comp = img.get("component", "") + if "microshift-bootc" not in comp: + continue + container_image = img.get("containerImage", "") + sha_match = re.search(r"@sha256:([0-9a-f]+)", container_image) + repo = container_image.split("@")[0] if "@" in container_image else container_image + arch = img.get("architecture", "") + rhel_match = re.search(r"rhel(\d+)", comp) + arch_key = arch + if rhel_match: + arch_key = f"{arch}/el{rhel_match.group(1)}" + images.append({ + "component": comp, + "architecture": arch, + "arch_key": arch_key, + "containerImage": container_image, + "sha": sha_match.group(1) if sha_match else None, + "repo": repo, + }) + + return { + "spec_type": spec.get("type"), + "images": images if images else None, + } + + +def fetch_shipment_mr_approvals(project_id, mr_iid): + """Check whether a GitLab MR has been approved. + + Returns: + dict: {"approved": bool, "approvers": [str], "approvals_required": int} + or None on failure. + """ + resp = _gitlab_get(f"projects/{project_id}/merge_requests/{mr_iid}/approvals") + if resp is None or resp.status_code != 200: + return None + try: + data = resp.json() + except (json.JSONDecodeError, ValueError): + return None + approvers = [a.get("user", {}).get("username", "unknown") + for a in data.get("approved_by", [])] + return { + "approved": data.get("approved", False), + "approvers": approvers, + "approvals_required": data.get("approvals_required", 0), + } diff --git a/plugins/microshift-release/scripts/lib/pyxis.py b/plugins/microshift-release/scripts/lib/pyxis.py index 73e97758..b3031d4b 100644 --- a/plugins/microshift-release/scripts/lib/pyxis.py +++ b/plugins/microshift-release/scripts/lib/pyxis.py @@ -7,25 +7,94 @@ import requests +try: + from requests_gssapi import HTTPSPNEGOAuth + _KERBEROS_AUTH = HTTPSPNEGOAuth() +except ImportError: + _KERBEROS_AUTH = None + PYXIS_BASE_URL = "https://catalog.redhat.com/api/containers/v1" -PYXIS_STAGE_BASE_URL = "https://catalog.stage.redhat.com/api/containers/v1" -BOOTC_REPO_PATH = ( - "repositories/registry/registry.access.redhat.com" - "/repository/openshift4/microshift-bootc-rhel9/images" -) -BOOTC_STAGE_REPO_PATH = ( - "repositories/registry/registry.stage.redhat.io" - "/repository/openshift4/microshift-bootc-rhel9/images" -) +PYXIS_STAGE_BASE_URL = "https://pyxis.stage.engineering.redhat.com/v1" CONTAINERFILE_URL_TEMPLATE = ( "https://raw.githubusercontent.com/openshift/microshift" "/{commit}/packaging/images/bootc/Containerfile" ) -_CATALOG_URLS = { - "prod": f"{PYXIS_BASE_URL}/{BOOTC_REPO_PATH}", - "stage": f"{PYXIS_STAGE_BASE_URL}/{BOOTC_STAGE_REPO_PATH}", +_BOOTC_REPO_TEMPLATE = { + "prod": ( + PYXIS_BASE_URL + "/repositories/registry/registry.access.redhat.com" + "/repository/openshift4/microshift-bootc-rhel{rhel}/images" + ), + "stage": ( + PYXIS_STAGE_BASE_URL + "/repositories/registry/registry.access.redhat.com" + "/repository/openshift4/microshift-bootc-rhel{rhel}/images" + ), +} + +_GRAPHQL_URLS = { + "prod": "https://catalog.redhat.com/api/containers/graphql/", + "stage": "https://catalog.stage.redhat.com/api/containers/graphql/", +} + +_REPO_BASE_URLS = { + "prod": PYXIS_BASE_URL, + "stage": PYXIS_STAGE_BASE_URL, +} + +_BOOTC_REPO_NAME = "openshift{major}/microshift-bootc-rhel{rhel}" + +_GRAPHQL_IMAGES_QUERY = """ +query GET_REPOSITORY_BY_ID_IMAGES_HISTORY( + $id: ObjectIDFilterScalar + $page: Int! = 0 + $page_size: Int! + $filter: ContainerImageFilter + $sort_by: [SortBy] +) { + ContainerRepository: get_repository(id: $id) { + data { + registry + repository + edges { + images(page: $page, page_size: $page_size, filter: $filter, sort_by: $sort_by) { + total + data { + _id + image_id + docker_image_digest + architecture + parsed_data { + labels { + name + value + } + env_variables + } + freshness_grades { + grade + } + container_grades { + status + status_message + } + repositories { + repository + push_date + manifest_schema2_digest + tags { + name + } + } + } + } + } + } + } } +""" + +# Cache repo IDs to avoid repeated lookups within a run +_repo_id_cache = {} _LABEL_COMMIT_ID = "io.openshift.build.commit.id" _LABEL_COMMIT_URL = "io.openshift.build.commit.url" @@ -34,16 +103,28 @@ logger = logging.getLogger(__name__) -def _catalog_url(catalog="prod"): - """Return the Pyxis images URL for the given catalog. +def _catalog_auth(catalog): + """Return auth object for Pyxis requests. Stage requires Kerberos.""" + if catalog == "stage": + if _KERBEROS_AUTH is None: + logger.warning("requests-gssapi not installed — stage Pyxis " + "requires Kerberos (pip install requests-gssapi)") + return _KERBEROS_AUTH + return None + + +def _catalog_url(catalog="prod", rhel=9): + """Return the Pyxis images URL for the given catalog and RHEL version. Args: catalog: "prod" or "stage". + rhel: RHEL version (9 or 10). Default 9 for backward compatibility. Returns: str: Full API URL for the bootc images endpoint. """ - return _CATALOG_URLS.get(catalog, _CATALOG_URLS["prod"]) + template = _BOOTC_REPO_TEMPLATE.get(catalog, _BOOTC_REPO_TEMPLATE["prod"]) + return template.format(rhel=rhel) def _fetch_page(page, catalog="prod"): @@ -139,6 +220,10 @@ def _parse_image_metadata(image): ) assembly_version = assembly_match.group(1) if assembly_match else None + freshness = image.get("freshness_grades") or [] + freshness_grade = freshness[-1].get("grade") if freshness else None + container_grades = image.get("container_grades") or {} + return { "image_id": image.get("_id"), "commit_id": commit_id, @@ -151,6 +236,8 @@ def _parse_image_metadata(image): "version_tags": version_tags, "assembly_version": assembly_version, "last_update_date": image.get("last_update_date"), + "freshness_grade": freshness_grade, + "container_grade_status": container_grades.get("status"), } @@ -457,12 +544,14 @@ def fetch_all_bootc_images(catalog="prod", pages=5): return images -def check_catalog_image(version, catalog="prod"): +def check_catalog_image(version, catalog="prod", arch="amd64", rhel=9): """Check if a specific version's bootc image exists in the catalog. Args: version: Full version string, e.g., "4.21.8". catalog: "prod" or "stage". + arch: Architecture to query, e.g., "amd64" or "arm64". + rhel: RHEL version (9 or 10). Returns: dict: {valid: bool, reason: str, image: dict | None, catalog: str} @@ -470,26 +559,21 @@ def check_catalog_image(version, catalog="prod"): tag = re.sub(r"-(ec|rc)\.\d+$", "", version) assembly_pattern = re.compile(rf"\bassembly\.{re.escape(tag)}\b") - url = _catalog_url(catalog) + url = _catalog_url(catalog, rhel=rhel) for page in range(5): params = { - "filter": "architecture==amd64", + "filter": f"architecture=={arch}", "page_size": 100, "page": page, } try: - resp = requests.get(url, params=params, timeout=30) + resp = requests.get(url, params=params, timeout=30, + auth=_catalog_auth(catalog), + verify=(catalog != "stage")) resp.raise_for_status() data = resp.json() - except requests.HTTPError as e: - if catalog == "stage" and e.response.status_code == 403: - logger.warning("Stage catalog unreachable (403), " - "falling back to prod") - return check_catalog_image(version, catalog="prod") - return {"valid": False, - "reason": f"Catalog query failed ({catalog}): {e}", - "image": None, "catalog": catalog} - except (requests.RequestException, json.JSONDecodeError) as e: + except (requests.HTTPError, requests.RequestException, + json.JSONDecodeError) as e: return {"valid": False, "reason": f"Catalog query failed ({catalog}): {e}", "image": None, "catalog": catalog} @@ -516,6 +600,151 @@ def check_catalog_image(version, catalog="prod"): "image": None, "catalog": catalog} +def _find_repo_id(catalog, repo_name): + """Discover the Pyxis ObjectID for a container repository. + + Queries the REST API to find the repository by name, + then caches the result for subsequent calls. + + Returns: + str or None: The repository ObjectID. + """ + cache_key = (catalog, repo_name) + if cache_key in _repo_id_cache: + return _repo_id_cache[cache_key] + + base = _REPO_BASE_URLS.get(catalog, _REPO_BASE_URLS["prod"]) + url = f"{base}/repositories" + params = {"filter": f"repository=={repo_name}", "page_size": 1} + try: + resp = requests.get(url, params=params, timeout=15, + auth=_catalog_auth(catalog), + verify=(catalog != "stage")) + if resp.status_code != 200: + logger.warning("Repo search returned HTTP %d for %s on %s", + resp.status_code, repo_name, catalog) + return None + data = resp.json() + items = data.get("data", []) + if items: + repo_id = items[0].get("_id") + _repo_id_cache[cache_key] = repo_id + return repo_id + except (requests.RequestException, json.JSONDecodeError) as exc: + logger.debug("Repo ID lookup failed for %s on %s: %s", + repo_name, catalog, exc) + return None + + +def check_catalog_image_graphql(version, catalog="prod", arch="amd64", rhel=9): + """Check if a bootc image exists in the catalog. + + Uses the Pyxis GraphQL API for prod, REST API for stage (no GraphQL + on the stage Pyxis instance). + + Args: + version: Full version string, e.g., "4.21.8". + catalog: "prod" or "stage". + arch: Architecture, e.g., "amd64" or "arm64". + rhel: RHEL version (9 or 10). + + Returns: + dict: {valid: bool, reason: str, image: dict | None, catalog: str} + """ + if catalog == "stage": + return check_catalog_image(version, catalog="stage", arch=arch, rhel=rhel) + + major = version.split(".")[0] + repo_name = _BOOTC_REPO_NAME.format(major=major, rhel=rhel) + repo_id = _find_repo_id(catalog, repo_name) + if not repo_id: + return {"valid": False, + "reason": f"Repository {repo_name} not found in {catalog}", + "image": None, "catalog": catalog} + + graphql_url = _GRAPHQL_URLS.get(catalog, _GRAPHQL_URLS["prod"]) + page_size = 250 + + # GA/Z-stream: tags contain "assembly.X.Y.Z" + # EC/RC: tags contain "vX.Y.Z-ec.N" or "vX.Y.Z-rc.N" + base_version = re.sub(r"-(ec|rc)\.\d+$", "", version) + tag_patterns = [ + re.compile(rf"\bassembly\.{re.escape(base_version)}\b"), + re.compile(rf"^v{re.escape(version)}$"), + ] + + for page in range(5): + variables = { + "id": repo_id, + "page": page, + "page_size": page_size, + "filter": { + "and": [ + {"repositories_elemMatch": { + "and": [{"repository": {"eq": repo_name}}] + }} + ] + }, + "sort_by": [ + {"field": "repositories.push_date", "order": "DESC"}, + {"field": "repositories.repository", "order": "ASC"}, + ], + } + + try: + resp = requests.post( + graphql_url, + json={"query": _GRAPHQL_IMAGES_QUERY, "variables": variables}, + timeout=30, + ) + resp.raise_for_status() + result = resp.json() + except (requests.RequestException, json.JSONDecodeError) as exc: + return {"valid": False, + "reason": f"GraphQL query failed ({catalog}): {exc}", + "image": None, "catalog": catalog} + + data = result.get("data") or {} + repo_data = (data.get("ContainerRepository") or {}).get("data") or {} + edges = (repo_data.get("edges") or {}) + images_data = (edges.get("images") or {}).get("data", []) + + for image in images_data: + if image.get("architecture") != arch: + continue + for repo in image.get("repositories", []): + for t in repo.get("tags", []): + tag_name = t.get("name", "") + if any(p.search(tag_name) for p in tag_patterns): + all_tags = [{"name": tg.get("name", "")} + for r in image.get("repositories", []) + for tg in r.get("tags", [])] + base_meta = _parse_image_metadata(image) + metadata = { + "image_id": image.get("_id"), + "docker_image_digest": image.get("docker_image_digest"), + "commit_id": base_meta["commit_id"], + "commit_short": base_meta["commit_short"], + "freshness_grade": base_meta.get("freshness_grade"), + "container_grade_status": base_meta.get("container_grade_status"), + "tags": all_tags, + "matched_tag": tag_name, + } + return { + "valid": True, + "reason": f"Image found in {catalog} catalog (tag {tag_name})", + "image": metadata, + "catalog": catalog, + } + + if len(images_data) < page_size: + break + + return {"valid": False, + "reason": f"Image for {version} ({arch}) not found in {catalog} catalog", + "image": None, "catalog": catalog} + + def fetch_containerfile(commit_hash): """Fetch the Containerfile content from GitHub for a specific commit. diff --git a/plugins/microshift-release/scripts/requirements.txt b/plugins/microshift-release/scripts/requirements.txt index b1903f5b..dc64e1ef 100644 --- a/plugins/microshift-release/scripts/requirements.txt +++ b/plugins/microshift-release/scripts/requirements.txt @@ -1,2 +1,3 @@ requests>=2.32.2,<3 +requests-gssapi>=1.3.0,<2 pyyaml>=6.0,<7 diff --git a/plugins/microshift-release/scripts/unit_tests/test_advisory_promotion.py b/plugins/microshift-release/scripts/unit_tests/test_advisory_promotion.py new file mode 100644 index 00000000..f0d45850 --- /dev/null +++ b/plugins/microshift-release/scripts/unit_tests/test_advisory_promotion.py @@ -0,0 +1,373 @@ +"""Unit tests for advisory promotion checks (Phase 3).""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from advisory_promotion import ( # noqa: E402 + check_in_advisory, + check_in_catalog, + check_repository, + check_image_sha, + check_tag_commit_id, + check_tag_build_date, + check_no_xy0_tag, + check_chi_freshness, + check_image_sha_distinct, + check_shipment_mr_approved, + format_text_short, + format_text_full, + _expected_repo, + _all_check_ids, + _rhel_versions, + _variants, +) +from validate_artifacts import classify_version # noqa: E402 + + +def _advisory(images=None, spec_type=None): + return {"spec_type": spec_type, "images": images} + + +def _image(arch="amd64", rhel=9, sha="abc123"): + repo = _expected_repo(rhel) + comp = f"microshift-bootc-rhel{rhel}" + return { + "component": comp, + "architecture": arch, + "arch_key": f"{arch}/el{rhel}", + "containerImage": f"{repo}@sha256:{sha}", + "sha": sha, + "repo": repo, + } + + +def _catalog(tags=None, commit_id=None, valid=True, freshness_grade=None): + image = { + "tags": tags or [], + "commit_id": commit_id, + "commit_short": commit_id[:7] if commit_id else None, + "image_id": "test-id", + "freshness_grade": freshness_grade, + } + return {"valid": valid, "image": image, "catalog": "stage"} + + +def _version(v="4.18.3"): + return classify_version(v) + + +# ── Version-based variant selection ────────────────────────────── + + +class TestRhelVersions(unittest.TestCase): + def test_el9_only_old(self): + self.assertEqual(_rhel_versions(_version("4.18.3")), [9]) + + def test_el9_only_422_1(self): + self.assertEqual(_rhel_versions(_version("4.22.1")), [9]) + + def test_el9_el10_422_2(self): + self.assertEqual(_rhel_versions(_version("4.22.2")), [9, 10]) + + def test_el9_el10_423(self): + self.assertEqual(_rhel_versions(_version("4.23.0")), [9, 10]) + + def test_el9_el10_422_10(self): + self.assertEqual(_rhel_versions(_version("4.22.10")), [9, 10]) + + +class TestVariants(unittest.TestCase): + def test_el9_only(self): + v = _variants(_version("4.18.3")) + self.assertEqual(v, [("amd64", 9), ("arm64", 9)]) + + def test_el9_el10(self): + v = _variants(_version("4.22.2")) + self.assertEqual(v, [("amd64", 9), ("amd64", 10), + ("arm64", 9), ("arm64", 10)]) + + +class TestCheckIds(unittest.TestCase): + def test_el9_only(self): + ids = _all_check_ids(_version("4.18.3")) + self.assertTrue(all("el10" not in i for i in ids)) + el9_ids = [i for i in ids if i.startswith("amd64_el9_")] + self.assertEqual(len(el9_ids), 13) + + def test_el9_el10(self): + ids = _all_check_ids(_version("4.22.2")) + el9_variant = [i for i in ids if i.startswith(("amd64_el9_", "arm64_el9_"))] + el10_variant = [i for i in ids if i.startswith(("amd64_el10_", "arm64_el10_"))] + self.assertEqual(len(el9_variant), 26) # 13 per arch * 2 arches + self.assertEqual(len(el10_variant), 26) + self.assertIn("advisory_sha_distinct_el9", ids) + self.assertIn("advisory_sha_distinct_el10", ids) + self.assertIn("advisory_sha_distinct_el9", ids) + self.assertIn("advisory_sha_distinct_el10", ids) + + +# ── Per-variant: in_advisory ───────────────────────────────────── + + +class TestInAdvisory(unittest.TestCase): + def test_present(self): + adv = _advisory([_image("amd64", 9), _image("arm64", 9)]) + r = check_in_advisory("amd64_el9", "amd64", 9, adv) + self.assertEqual(r["status"], "PASS") + + def test_missing(self): + adv = _advisory([_image("amd64", 9)]) + r = check_in_advisory("arm64_el9", "arm64", 9, adv) + self.assertEqual(r["status"], "FAIL") + + def test_el10_present(self): + adv = _advisory([_image("amd64", 10)]) + r = check_in_advisory("amd64_el10", "amd64", 10, adv) + self.assertEqual(r["status"], "PASS") + + def test_no_advisory(self): + r = check_in_advisory("amd64_el9", "amd64", 9, None) + self.assertEqual(r["status"], "FAIL") + + +# ── Per-variant: repository ────────────────────────────────────── + + +class TestRepository(unittest.TestCase): + def test_correct_el9(self): + adv = _advisory([_image("amd64", 9)]) + r = check_repository("amd64_el9", "amd64", 9, 4, adv) + self.assertEqual(r["status"], "PASS") + + def test_correct_el10(self): + adv = _advisory([_image("amd64", 10)]) + r = check_repository("amd64_el10", "amd64", 10, 4, adv) + self.assertEqual(r["status"], "PASS") + + def test_wrong_rhel(self): + adv = _advisory([_image("amd64", 9)]) + # Checking el10 but image is el9 — won't find it + r = check_repository("amd64_el10", "amd64", 10, 4, adv) + self.assertEqual(r["status"], "WARN") + + def test_wrong_repo(self): + img = _image("amd64", 9) + img["repo"] = "registry.redhat.io/wrong" + adv = _advisory([img]) + r = check_repository("amd64_el9", "amd64", 9, 4, adv) + self.assertEqual(r["status"], "FAIL") + + +# ── Per-variant: image_sha ─────────────────────────────────────── + + +class TestImageSha(unittest.TestCase): + def test_present(self): + adv = _advisory([_image("amd64", 9, sha="deadbeef")]) + r = check_image_sha("amd64_el9", "amd64", 9, adv) + self.assertEqual(r["status"], "PASS") + + def test_empty(self): + img = _image("amd64", 9) + img["sha"] = "" + adv = _advisory([img]) + r = check_image_sha("amd64_el9", "amd64", 9, adv) + self.assertEqual(r["status"], "FAIL") + + +# ── Per-variant: tag_commit_id ─────────────────────────────────── + + +class TestTagCommitId(unittest.TestCase): + def test_matches(self): + tags = [{"name": "v4.18-202606151054.p2.g7f7539e.assembly.4.18.3.el9"}] + r = check_tag_commit_id("amd64_el9", "stage", _catalog(tags=tags, commit_id="7f7539e123456")) + self.assertEqual(r["status"], "PASS") + + def test_mismatch(self): + tags = [{"name": "v4.18-202606151054.p2.g7f7539e.assembly.4.18.3.el9"}] + r = check_tag_commit_id("amd64_el9", "stage", _catalog(tags=tags, commit_id="abc1234567890")) + self.assertEqual(r["status"], "FAIL") + + def test_no_catalog(self): + r = check_tag_commit_id("amd64_el9", "stage", None) + self.assertEqual(r["status"], "SKIP") + + +# ── Per-variant: tag_build_date ────────────────────────────────── + + +class TestTagBuildDate(unittest.TestCase): + def test_valid(self): + tags = [{"name": "v4.18-202606151054.p2.g7f7539e.assembly.4.18.3.el9"}] + r = check_tag_build_date("amd64_el9", "stage", _catalog(tags=tags)) + self.assertEqual(r["status"], "PASS") + self.assertIn("2026-06-15 10:54", r["reason"]) + + def test_no_catalog(self): + r = check_tag_build_date("amd64_el9", "stage", None) + self.assertEqual(r["status"], "SKIP") + + +# ── Per-variant: no_xy0_tag ────────────────────────────────────── + + +class TestNoXY0Tag(unittest.TestCase): + def test_zstream_clean(self): + tags = [{"name": "v4.18-202606151054.p2.g7f7539e.assembly.4.18.3.el9"}] + r = check_no_xy0_tag("amd64_el9", "stage", _catalog(tags=tags), _version("4.18.3")) + self.assertEqual(r["status"], "PASS") + + def test_zstream_has_xy0(self): + tags = [ + {"name": "v4.18-202606151054.p2.g7f7539e.assembly.4.18.3.el9"}, + {"name": "v4.18-202601011200.p2.gabc1234.assembly.4.18.0.el9"}, + ] + r = check_no_xy0_tag("amd64_el9", "stage", _catalog(tags=tags), _version("4.18.3")) + self.assertEqual(r["status"], "FAIL") + + def test_xy0_skipped(self): + r = check_no_xy0_tag("amd64_el9", "stage", _catalog(), _version("4.18.0")) + self.assertEqual(r["status"], "SKIP") + + +# ── Per-variant: chi_freshness ──────────────────────────────────── + + +class TestCHIFreshness(unittest.TestCase): + def test_grade_a(self): + r = check_chi_freshness("amd64_el9", "stage", _catalog(freshness_grade="A")) + self.assertEqual(r["status"], "PASS") + self.assertIn("A", r["reason"]) + + def test_grade_d(self): + r = check_chi_freshness("amd64_el9", "stage", _catalog(freshness_grade="D")) + self.assertEqual(r["status"], "FAIL") + self.assertIn("D", r["reason"]) + + def test_no_grade(self): + r = check_chi_freshness("amd64_el9", "stage", _catalog()) + self.assertEqual(r["status"], "WARN") + + def test_not_fetched(self): + r = check_chi_freshness("amd64_el9", "prod", None) + self.assertEqual(r["status"], "SKIP") + + +# ── Per-variant: check_in_catalog (stage/prod phase) ────────────── + + +class TestCheckInCatalog(unittest.TestCase): + def test_stage_mode_skips_prod(self): + r = check_in_catalog("amd64_el9", "prod", {}, _version("4.18.3"), phase="stage") + self.assertEqual(r["status"], "SKIP") + self.assertIn("stage mode", r["reason"]) + + def test_prod_mode_checks_prod(self): + r = check_in_catalog("amd64_el9", "prod", {"valid": True}, _version("4.18.3"), phase="prod") + self.assertEqual(r["status"], "PASS") + + def test_ec_skips_prod_regardless(self): + r = check_in_catalog("amd64_el9", "prod", {}, _version("5.0.0-ec.3"), phase="prod") + self.assertEqual(r["status"], "SKIP") + + def test_stage_always_checked(self): + r = check_in_catalog("amd64_el9", "stage", {"valid": True}, _version("4.18.3"), phase="stage") + self.assertEqual(r["status"], "PASS") + + +# ── Global: image_sha_distinct ─────────────────────────────────── + + +class TestImageShaDistinct(unittest.TestCase): + def test_distinct(self): + adv = _advisory([_image("amd64", 9, "aaa"), _image("arm64", 9, "bbb")]) + r = check_image_sha_distinct(9, adv) + self.assertEqual(r["status"], "PASS") + + def test_identical(self): + adv = _advisory([_image("amd64", 9, "same"), _image("arm64", 9, "same")]) + r = check_image_sha_distinct(9, adv) + self.assertEqual(r["status"], "FAIL") + + def test_el10(self): + adv = _advisory([_image("amd64", 10, "aaa"), _image("arm64", 10, "bbb")]) + r = check_image_sha_distinct(10, adv) + self.assertEqual(r["status"], "PASS") + self.assertEqual(r["check"], "advisory_sha_distinct_el10") + + +# ── Global: shipment_mr_approved ───────────────────────────────── + + +class TestShipmentMRApproved(unittest.TestCase): + def test_skipped(self): + r = check_shipment_mr_approved({"skipped": True, "reason": "No token"}) + self.assertEqual(r["status"], "WARN") + + def test_not_found(self): + r = check_shipment_mr_approved({"found": False, "reason": "Not found"}) + self.assertEqual(r["status"], "FAIL") + + +# ── Formatting ─────────────────────────────────────────────────── + + +class TestFormatShort(unittest.TestCase): + def test_grouped_by_variant(self): + results = [ + {"check": "amd64_el9_advisory_image_present", "status": "PASS", + "reason": "amd64/el9 present", "details": []}, + {"check": "arm64_el9_advisory_image_present", "status": "PASS", + "reason": "arm64/el9 present", "details": []}, + {"check": "shipment_mr_approved", "status": "PASS", + "reason": "MR approved", "details": []}, + ] + vi = _version("4.18.3") + out = format_text_short("4.18.3", results, vi) + self.assertIn("── amd64_el9", out) + self.assertIn("── arm64_el9", out) + self.assertIn("── Global", out) + + def test_skips_shown(self): + results = [ + {"check": "amd64_el9_catalog_prod_present", "status": "SKIP", + "reason": "N/A (EC not shipped to prod)", "details": []}, + ] + vi = _version("4.18.3") + out = format_text_short("4.18.3", results, vi) + self.assertIn("amd64_el9_catalog_prod_present", out) + self.assertIn("N/A", out) + + def test_el10_sections(self): + results = [ + {"check": "amd64_el9_advisory_image_present", "status": "PASS", + "reason": "present", "details": []}, + {"check": "amd64_el10_advisory_image_present", "status": "PASS", + "reason": "present", "details": []}, + ] + vi = _version("4.22.2") + out = format_text_short("4.22.2", results, vi) + self.assertIn("── amd64_el9", out) + self.assertIn("── amd64_el10", out) + + +class TestFormatFull(unittest.TestCase): + def test_sections(self): + results = [ + {"check": "amd64_el9_advisory_image_present", "status": "PASS", + "reason": "present", "details": []}, + {"check": "shipment_mr_approved", "status": "PASS", + "reason": "approved", "details": []}, + ] + vi = _version("4.18.3") + out = format_text_full("4.18.3", results, vi) + self.assertIn("## amd64_el9", out) + self.assertIn("## Global", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/microshift-release/skills/advisory-promotion/SKILL.md b/plugins/microshift-release/skills/advisory-promotion/SKILL.md new file mode 100644 index 00000000..e4d5790d --- /dev/null +++ b/plugins/microshift-release/skills/advisory-promotion/SKILL.md @@ -0,0 +1,210 @@ +--- +name: microshift-release:advisory-promotion +argument-hint: [--prod] [--verbose] +description: Validate Konflux bootc advisory promotion for QE sign-off — verify advisory YAML, catalog presence, shipment MR, and commit provenance +user-invocable: true +allowed-tools: Bash +--- + +# microshift-release:advisory-promotion + +## Synopsis + +```bash +/microshift-release:advisory-promotion [--prod] [--verbose] +``` + +## Description + +Phase 3 of the MicroShift release process: verify that a Konflux-built bootc advisory is ready for QE sign-off and shipping. Validates three data sources: + +- **advisory.yaml** — image presence, repository, SHA, and advisory type +- **Pyxis catalog** (via GraphQL) — stage and prod catalog presence, assembly tags, commit provenance +- **Shipment MR** — YAML filename, NVR commit vs Brew, release type, MR approval + +All per-image checks run independently per variant (arch + RHEL version). For versions 4.22.2+, both el9 and el10 bootc images are checked. Repository names are version-aware (`openshift4` for 4.x, `openshift5` for 5.x). + +Supports Z-stream, X/Y GA, RC, and EC release types. Requires version 4.18+. + +## Prerequisites + +| Requirement | Needed for | Mandatory? | +|---|---|---| +| VPN | GitLab API, Brew RPM lookup | Yes — shipment and NVR checks WARN without it | +| `GITLAB_API_TOKEN` | Shipment MR fetch, MR approval check | Yes — shipment checks WARN without it | +| Internet | Pyxis catalog queries (GraphQL) | Yes for catalog checks | + +## Arguments + +- `version` (required): Full version string (4.18+) + - Z-stream: `4.20.26` + - X/Y (GA): `4.22.0` + - RC: `4.22.0-rc.2` + - EC: `5.0.0-ec.3` +- `--prod` (optional): Check both stage and prod catalogs (default: stage only, prod checks skipped) +- `--verbose` (optional): Show detailed markdown report with evidence per check + +## Scripts Directory + +```bash +SCRIPTS_DIR=plugins/microshift-release/scripts +``` + +## Implementation + +### Step 1: Parse Arguments + +1. Extract `version` from `$ARGUMENTS` — the first non-flag token +2. Pass through `--prod`, `--verbose`, and `--json` flags if present + +### Step 2: Run the Script + +```bash +bash $SCRIPTS_DIR/advisory_promotion.sh [--prod] [--verbose] +``` + +Display stderr only if the script exits non-zero. + +### Step 3: Display Output + +Display output **verbatim** — do not reformat, summarize, or add commentary. The script produces deterministic pre-formatted text. + +### Step 4: Handle Errors + +If the script exits non-zero: + +- **VPN errors**: Connect to VPN (GitLab API and Brew require it) +- **Missing GITLAB_API_TOKEN**: `export GITLAB_API_TOKEN=` for shipment MR and approval checks +- **Version too low**: Advisory promotion requires 4.18+ (Konflux builds) + +## Checks Performed + +All per-image checks run independently per variant (`{arch}_el{rhel}`). For versions < 4.22.2 only el9 is checked; for 4.22.2+ both el9 and el10. + +### Per-variant checks (`{arch}_el{rhel}_*`) + +**From advisory.yaml** (`rhtap-release/advisories/.../advisory.yaml`): + +| Check | Description | +|---|---| +| `{v}_advisory_image_present` | Variant is present in `spec.content.images` | +| `{v}_advisory_repository` | Image references the correct `registry.stage.redhat.io/openshift{major}/microshift-bootc-rhel{rhel}` | +| `{v}_advisory_image_sha` | Advisory contains a non-empty image SHA for this variant | + +**From Pyxis catalog** (GraphQL API, both stage and prod): + +| Check | Description | +|---|---| +| `{v}_catalog_stage_present` | Image found in stage catalog | +| `{v}_catalog_stage_tag_commit` | Assembly tag commit hash matches stage catalog image labels | +| `{v}_catalog_stage_tag_date` | Assembly tag contains a valid build date timestamp (stage) | +| `{v}_catalog_stage_no_xy0_tag` | Z-stream only: no X.Y.0 assembly tag on stage image | +| `{v}_catalog_stage_chi` | Container Health Index grade is A (stage) | +| `{v}_catalog_prod_present` | Image found in prod catalog (skipped in stage mode / EC/RC) | +| `{v}_catalog_prod_tag_commit` | Assembly tag commit hash matches prod catalog image labels | +| `{v}_catalog_prod_tag_date` | Assembly tag contains a valid build date timestamp (prod) | +| `{v}_catalog_prod_no_xy0_tag` | Z-stream only: no X.Y.0 assembly tag on prod image | +| `{v}_catalog_prod_chi` | Container Health Index grade is A (prod) | + +### Global checks + +**From advisory.yaml:** + +| Check | Description | +|---|---| +| `advisory_type` | `spec.type` is RHBA/RHSA (z-stream, EC, RC) or RHEA (X.Y.0) | +| `advisory_sha_distinct_el{rhel}` | amd64 and arm64 SHAs are different per RHEL version | + +**From shipment MR** (`ocp-shipment-data` GitLab repo): + +| Check | Description | +|---|---| +| `shipment_type` | `releaseNotes.type` matches expected advisory type | +| `shipment_filename` | YAML path matches `shipment/ocp/openshift-{minor}/.../{version}.microshift-bootc.{timestamp}.yaml` | +| `shipment_nvr_commit` | Commit hash in shipment `snapshot.nvrs` matches the Brew RPM build commit | +| `shipment_mr_approved` | Shipment MR has required approvals | + +## Output Format + +**Short (default):** All checks shown, grouped by variant. Skipped checks use ⏭️. + +```text +Advisory Promotion: 4.20.26 + +── amd64_el9 ─────────────────────────────────────────────── +✅ amd64_el9_advisory_image_present amd64/el9 present +✅ amd64_el9_advisory_repository registry.stage.redhat.io/openshift4/microshift-bootc-rhel9 +✅ amd64_el9_advisory_image_sha sha256:f839eb91f716 +✅ amd64_el9_catalog_stage_present Found in stage catalog +✅ amd64_el9_catalog_stage_tag_commit Commit b79e4b0 matches catalog +✅ amd64_el9_catalog_stage_tag_date 2026-06-19 09:02 +✅ amd64_el9_catalog_stage_no_xy0_tag No 4.20.0 tags (7 checked) +✅ amd64_el9_catalog_stage_chi CHI grade A +⏭️ amd64_el9_catalog_prod_present N/A (stage mode) +⏭️ amd64_el9_catalog_prod_tag_commit N/A (prod not queried) +⏭️ amd64_el9_catalog_prod_tag_date N/A (prod not queried) +⏭️ amd64_el9_catalog_prod_no_xy0_tag N/A (prod not queried) +⏭️ amd64_el9_catalog_prod_chi N/A (prod not queried) + +── arm64_el9 ─────────────────────────────────────────────── +✅ arm64_el9_advisory_image_present arm64/el9 present +... + +── Global ────────────────────────────────────────────────── +✅ advisory_type spec.type = RHBA +✅ shipment_type releaseNotes.type = RHBA +✅ shipment_filename shipment/ocp/openshift-4.20/openshift-4-20/prod/4.20.26.microshift-bootc...yaml +✅ shipment_nvr_commit Commit b79e4b0 matches Brew +✅ advisory_sha_distinct_el9 SHAs are distinct +✅ shipment_mr_approved MR !594 approved by tlove, knarra, adobes +``` + +With `--prod`, both stage and prod catalog checks run: + +```text +✅ amd64_el9_catalog_stage_present Found in stage catalog +✅ amd64_el9_catalog_stage_chi CHI grade A +✅ amd64_el9_catalog_prod_present Found in prod catalog +✅ amd64_el9_catalog_prod_chi CHI grade A +``` + +On failure, details appear below the failing check: + +```text +❌ amd64_el9_advisory_repository Wrong repository: registry.redhat.io/openshift4/microshift-bootc-rhel9 + Expected: registry.stage.redhat.io/openshift4/microshift-bootc-rhel9 + Got: registry.redhat.io/openshift4/microshift-bootc-rhel9 +``` + +For EC/RC, prod catalog checks are always skipped: + +```text +⏭️ amd64_el9_catalog_prod_present N/A (EC not shipped to prod) +``` + +**Verbose (--verbose):** Markdown table with full evidence per check, grouped by variant. + +## Examples + +```bash +/microshift-release:advisory-promotion 4.20.26 # Z-stream (el9 only) +/microshift-release:advisory-promotion 4.22.2 # Z-stream (el9 + el10) +/microshift-release:advisory-promotion 4.22.0 # X/Y GA +/microshift-release:advisory-promotion 5.0.0-ec.3 # Engineering Candidate +/microshift-release:advisory-promotion 4.22.0-rc.2 # Release Candidate +/microshift-release:advisory-promotion 4.20.26 --verbose # detailed report +``` + +## Notes + +- Read-only — does NOT modify advisories, tickets, or external state. No confirmation required. +- VPN is required for GitLab API access and Brew RPM commit comparison +- GITLAB_API_TOKEN enables shipment MR checks; without it those checks show WARN +- Catalog checks use the Pyxis GraphQL API (works for both stage and prod) +- Default mode is stage — prod catalog checks are skipped. Use `--prod` to check both catalogs +- For EC/RC, `catalog_prod_present` is always skipped (not shipped to prod) +- For versions 4.22.2+, el10 bootc images are also checked +- Repository names are version-aware: `openshift4` for 4.x, `openshift5` for 5.x +- Only supports versions 4.18+ (Konflux bootc builds) +- Nightly builds are skipped (no advisories) +- Exit code is non-zero if any check returns FAIL