diff --git a/.githooks/pre-commit b/.githooks/pre-commit old mode 100644 new mode 100755 diff --git a/AGENTS.md b/AGENTS.md index 0f2792b..976f3a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,12 @@ Single version (`0.2.0`) kept in sync across three files: Bump all three when releasing. +## Shared modules (lifecycle ↔ prow) + +`skills/prow/scripts/rhdh_prow/repo.py` and `skills/prow/scripts/rhdh_prow/yaml.py` are copies of `skills/lifecycle/scripts/rhdh_lifecycle/repo.py` and `skills/lifecycle/scripts/rhdh_lifecycle/yaml.py`. The only difference is the internal import path (`rhdh_prow.repo` vs `rhdh_lifecycle.repo`). When modifying either copy, update both to keep them in sync. + +`skills/prow/scripts/rhdh_prow/utils.py` is a subset of `skills/lifecycle/scripts/rhdh_lifecycle/utils.py`. When modifying either copy, update both to keep them in sync. + ## Agent skills ### Issue tracker diff --git a/skills/lifecycle/SKILL.md b/skills/lifecycle/SKILL.md new file mode 100644 index 0000000..40a90a4 --- /dev/null +++ b/skills/lifecycle/SKILL.md @@ -0,0 +1,68 @@ +--- +name: lifecycle +description: >- + Check version lifecycle and support status for platforms and integrations + used by RHDH. Covers OCP, AKS, EKS, GKE, RHDH releases, RHBK, Quay, + PostgreSQL, and any Red Hat product via the Product Life Cycles API. + Use when asking about version support, EOL dates, GA dates, support + phases, or planning version upgrades. Also use for "is X still + supported", "what versions should we test", or "when does X reach EOL". +--- +# Version Lifecycle Checks + +Check version lifecycle and support status for platforms and integrations used by RHDH. + +## Prerequisites + +- Python 3.9+ +- Internet connectivity for API access +- For configured K8s version display (AKS/EKS): local `openshift/release` checkout or `gh` CLI + +## Identify Platform + +What platform or integration lifecycle do you need to check? + +| Query matches | Workflow | +|---|---| +| "OCP", "OpenShift version", "OpenShift EOL", "OpenShift support" | `workflows/check-ocp.md` | +| "RHDH version", "Developer Hub release", "is RHDH 1.x supported" | `workflows/check-rhdh.md` | +| "AKS", "Azure Kubernetes" | `workflows/check-aks.md` | +| "EKS", "Amazon EKS" | `workflows/check-eks.md` | +| "GKE", "Google Kubernetes" | `workflows/check-gke.md` | +| "RHBK", "Keycloak", "Red Hat Build of Keycloak", "Quay", any Red Hat product | `workflows/check-redhat.md` | +| "PostgreSQL", "Postgres", "PG", "database versions" | `workflows/check-pg.md` | +| "all platforms", "full lifecycle check" | Run all workflows in sequence | + +After reading the workflow, follow it exactly. + +## Available Scripts + +All scripts support `--help` for usage details and `--json` for structured output. + +| Script | Purpose | +|--------|---------| +| `scripts/check_ocp_lifecycle.py` | OCP version lifecycle with EUS phases | +| `scripts/check_rhdh_lifecycle.py` | RHDH release lifecycle with OCP compatibility | +| `scripts/check_lifecycle.py` | Generic Red Hat product (RHBK, Quay, etc.) | +| `scripts/check_aks_lifecycle.py` | AKS K8s version lifecycle | +| `scripts/check_eks_lifecycle.py` | EKS K8s version lifecycle | +| `scripts/check_gke_lifecycle.py` | GKE K8s version lifecycle | +| `scripts/check_pg_lifecycle.py` | PostgreSQL lifecycle across cloud providers | + +## Library (`rhdh_lifecycle` package) + +Shared utilities used by both lifecycle and prow skills: + +| Module | Purpose | +|--------|---------| +| `rhdh_lifecycle.repo` | Resolve openshift/release repository root (local or remote) | +| `rhdh_lifecycle.yaml` | Read and parse YAML files from openshift/release | +| `rhdh_lifecycle.configured_versions` | Print configured K8s versions per branch | +| `rhdh_lifecycle.redhat` | Red Hat Product Life Cycles API client | +| `rhdh_lifecycle.ocp` | OCP version phase classification | +| `rhdh_lifecycle.rhdh` | RHDH release lifecycle data | +| `rhdh_lifecycle.pg` | PostgreSQL lifecycle across cloud providers | + +## Related Skills + +- **`prow`**: Manage Prow CI job configurations for RHDH in openshift/release diff --git a/skills/lifecycle/scripts/check_aks_lifecycle.py b/skills/lifecycle/scripts/check_aks_lifecycle.py new file mode 100644 index 0000000..de6b62a --- /dev/null +++ b/skills/lifecycle/scripts/check_aks_lifecycle.py @@ -0,0 +1,135 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""Check AKS Kubernetes version lifecycle using the official AKS release status API. + +Primary source: https://releases.aks.azure.com/parsed_data.json +Cross-verify: https://endoflife.date/api/azure-kubernetes-service.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone + +from rhdh_lifecycle.configured_versions import print_configured_versions +from rhdh_lifecycle.repo import resolve_repo_root +from rhdh_lifecycle.utils import fetch_json, filter_supported_eol_entries, ver_sort_key + +AKS_API_URL = "https://releases.aks.azure.com/parsed_data.json" +EOL_API_URL = "https://endoflife.date/api/azure-kubernetes-service.json" +CONFIG_DIR = "ci-operator/config/redhat-developer/rhdh" + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Check AKS K8s version lifecycle.") + parser.add_argument("--mapt-ref", help="Path to MAPT ref YAML (repo-relative)") + parser.add_argument("--test-pattern", help="Regex to match test names") + parser.add_argument("--config-dir", default=CONFIG_DIR, help="CI config directory") + parser.add_argument("--repo-dir", help="Path to openshift/release checkout") + parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") + args = parser.parse_args(argv) + + root, is_remote = resolve_repo_root(args.repo_dir) + + # Print configured versions if test pattern provided + if args.test_pattern and not args.json_output: + print_configured_versions( + args.config_dir, args.test_pattern, root, is_remote, args.mapt_ref + ) + + # Fetch AKS release data (primary source) + data = fetch_json(AKS_API_URL) + if not data: + sys.exit(1) + + # Extract supported versions from the first region + try: + regional = data["Sections"]["KubernetesSupportedVersions"]["Components"][ + "KubernetesVersions" + ]["RegionalStatuses"] + first_region = list(regional.values())[0][0]["Current"]["KubernetesVersionList"] + except (KeyError, IndexError, TypeError): + print("ERROR: Unexpected AKS API response structure", file=sys.stderr) + sys.exit(1) + + # Group by minor version + minor_versions = {} + for entry in first_region: + parts = entry["VersionName"].split(".") + minor = f"{parts[0]}.{parts[1]}" + if minor not in minor_versions: + minor_versions[minor] = {"is_lts": False, "is_preview": False} + if entry.get("IsLTS"): + minor_versions[minor]["is_lts"] = True + if entry.get("IsPreview"): + minor_versions[minor]["is_preview"] = True + + sorted_versions = sorted( + minor_versions.items(), + key=lambda x: ver_sort_key(x[0]), + reverse=True, + ) + + # Deprecated version + try: + deprecated = regional[list(regional.keys())[0]][0]["Current"].get( + "DeprecatedVersion", "N/A" + ) + except (KeyError, IndexError): + deprecated = "N/A" + + # Cross-verify with endoflife.date + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + eol_data = fetch_json(EOL_API_URL) + eol_supported = filter_supported_eol_entries(eol_data, today) if eol_data else [] + + # JSON output + if args.json_output: + result = { + "versions": [ + { + "version": ver, + "status": "LTS" + if info["is_lts"] + else ("Preview" if info["is_preview"] else "GA"), + } + for ver, info in sorted_versions + ], + "deprecated": deprecated, + "endoflife_date": [ + {"version": e["cycle"], "eol": str(e.get("eol", "N/A"))} for e in eol_supported + ], + } + json.dump(result, sys.stdout, indent=2) + print() + return + + # Human-readable output + print("=== AKS Release Status (releases.aks.azure.com) ===") + print("Supported minor versions (newest first):") + for ver, info in sorted_versions: + if info["is_lts"]: + status = "LTS" + elif info["is_preview"]: + status = "Preview" + else: + status = "GA" + print(f" {ver:<8s} {status}") + print(f"Recently deprecated: {deprecated}") + + print() + print("=== Cross-verify (endoflife.date) ===") + if not eol_data: + print("WARNING: Failed to fetch endoflife.date", file=sys.stderr) + return + for entry in eol_supported: + print(f" {entry['cycle']}\tEOL: {entry.get('eol', 'N/A')}") + + +if __name__ == "__main__": + main() diff --git a/skills/lifecycle/scripts/check_eks_lifecycle.py b/skills/lifecycle/scripts/check_eks_lifecycle.py new file mode 100644 index 0000000..1bc6bfc --- /dev/null +++ b/skills/lifecycle/scripts/check_eks_lifecycle.py @@ -0,0 +1,178 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""Check EKS Kubernetes version lifecycle using the official AWS EKS docs source. + +Primary source: awsdocs/amazon-eks-user-guide raw AsciiDoc on GitHub +Cross-verify: https://endoflife.date/api/amazon-eks.json +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone + +from rhdh_lifecycle.configured_versions import print_configured_versions +from rhdh_lifecycle.repo import resolve_repo_root +from rhdh_lifecycle.utils import fetch_json, filter_supported_eol_entries + +EKS_DOCS_URL = ( + "https://raw.githubusercontent.com/awsdocs/amazon-eks-user-guide" + "/mainline/latest/ug/versioning/kubernetes-versions.adoc" +) +EOL_API_URL = "https://endoflife.date/api/amazon-eks.json" +CONFIG_DIR = "ci-operator/config/redhat-developer/rhdh" + + +def fetch_text(url): + """Fetch text content from a URL.""" + req = urllib.request.Request(url, headers={"User-Agent": "rhdh-skill"}) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read().decode("utf-8") + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch {url}: {exc}", file=sys.stderr) + return None + + +def parse_supported_versions(docs): + """Extract supported versions and their tiers from the AsciiDoc.""" + section = "" + versions = [] + for line in docs.splitlines(): + if "Available versions on standard support" in line: + section = "Standard" + elif "Available versions on extended support" in line: + section = "Extended" + elif "Amazon EKS Kubernetes release calendar" in line: + section = "" + elif section and re.match(r"^\* `\d+\.\d+`$", line): + ver = line.strip("* `\n") + versions.append((ver, section)) + return versions + + +def parse_release_calendar(docs): + """Extract the release calendar table from the AsciiDoc.""" + lines = docs.splitlines() + in_table = False + entries = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.strip() == "|===": + in_table = not in_table + i += 1 + continue + if in_table and re.match(r"^\|`\d+\.\d+`", line): + # Next 4 lines are: upstream, eks_release, end_std, end_ext + version = line.lstrip("|").strip("`").strip() + fields = [] + for j in range(1, 5): + if i + j < len(lines): + fields.append(lines[i + j].lstrip("|").strip()) + else: + fields.append("N/A") + entries.append((version, *fields)) + i += 5 + continue + i += 1 + return entries + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Check EKS K8s version lifecycle.") + parser.add_argument("--mapt-ref", help="Path to MAPT ref YAML (repo-relative)") + parser.add_argument("--test-pattern", help="Regex to match test names") + parser.add_argument("--config-dir", default=CONFIG_DIR, help="CI config directory") + parser.add_argument("--repo-dir", help="Path to openshift/release checkout") + parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") + args = parser.parse_args(argv) + + root, is_remote = resolve_repo_root(args.repo_dir) + + # Print configured versions if test pattern provided + if args.test_pattern and not args.json_output: + print_configured_versions( + args.config_dir, args.test_pattern, root, is_remote, args.mapt_ref + ) + + # Fetch EKS docs source (primary) + docs = fetch_text(EKS_DOCS_URL) + if not docs: + sys.exit(1) + + versions = parse_supported_versions(docs) + calendar = parse_release_calendar(docs) + + # Cross-verify with endoflife.date + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + eol_data = fetch_json(EOL_API_URL) + eol_supported = filter_supported_eol_entries(eol_data, today) if eol_data else [] + + # JSON output + if args.json_output: + result = { + "versions": [{"version": ver, "tier": tier} for ver, tier in versions], + "calendar": [ + { + "version": e[0], + "upstream_release": e[1], + "eks_release": e[2], + "end_standard": e[3], + "end_extended": e[4], + } + for e in calendar + ], + "endoflife_date": [ + { + "version": e["cycle"], + "eol": str(e.get("eol", "N/A")), + "extended_support": str(e.get("extendedSupport", "N/A")), + } + for e in eol_supported + ], + } + json.dump(result, sys.stdout, indent=2) + print() + return + + # Human-readable output + print("=== EKS Version Support (awsdocs/amazon-eks-user-guide) ===") + print("Supported minor versions:") + for ver, tier in versions: + print(f" {ver:<8s} {tier}") + + print() + print("Release calendar:") + print( + f" {'VERSION':<8s} {'UPSTREAM RELEASE':<22s} {'EKS RELEASE':<22s} " + f"{'END STANDARD':<22s} {'END EXTENDED':<22s}" + ) + print( + f" {'-------':<8s} {'----------------':<22s} {'-----------':<22s} " + f"{'------------':<22s} {'------------':<22s}" + ) + for entry in calendar: + ver, upstream, eks_rel, end_std, end_ext = entry + print(f" {ver:<8s} {upstream:<22s} {eks_rel:<22s} {end_std:<22s} {end_ext:<22s}") + + print() + print("=== Cross-verify (endoflife.date) ===") + if not eol_data: + print("WARNING: Failed to fetch endoflife.date", file=sys.stderr) + return + for entry in eol_supported: + ext = entry.get("extendedSupport", "N/A") + print(f" {entry['cycle']}\tEOL: {entry.get('eol', 'N/A')}\tExtended: {ext}") + + +if __name__ == "__main__": + main() diff --git a/skills/lifecycle/scripts/check_gke_lifecycle.py b/skills/lifecycle/scripts/check_gke_lifecycle.py new file mode 100644 index 0000000..edef327 --- /dev/null +++ b/skills/lifecycle/scripts/check_gke_lifecycle.py @@ -0,0 +1,97 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = [] +# /// +"""Check GKE Kubernetes version lifecycle using endoflife.date API. + +Primary source: https://endoflife.date/api/google-kubernetes-engine.json + (auto-scraped from Google's GKE release notes) + +GKE uses a pre-existing long-running cluster whose version is NOT managed +in CI config. This script only reports available versions for reference. +""" + +import argparse +import json +import sys +from datetime import datetime, timezone + +from rhdh_lifecycle.utils import fetch_json, ver_sort_key + +API_URL = "https://endoflife.date/api/google-kubernetes-engine.json" + + +def is_supported(entry, today): + """Check if a GKE version still has any support.""" + eol = entry.get("eol", "N/A") + if eol == "N/A": + return True + if isinstance(eol, bool): + return not eol + return eol > today + + +def get_status(entry, today): + """Determine support status: Standard, Maintenance, or Unknown.""" + support = entry.get("support", "N/A") + if support == "N/A" or isinstance(support, bool): + return "Unknown" + if support > today: + return "Standard" + return "Maintenance" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check GKE Kubernetes version lifecycle using endoflife.date API." + ) + parser.add_argument( + "--json", + dest="json_output", + action="store_true", + help="Output as JSON", + ) + args = parser.parse_args(argv) + + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + data = fetch_json(API_URL) + if not data: + sys.exit(1) + + supported = [e for e in data if is_supported(e, today)] + supported.sort(key=lambda e: ver_sort_key(e["cycle"]), reverse=True) + + if args.json_output: + result = [] + for e in supported: + result.append( + { + "version": e["cycle"], + "status": get_status(e, today), + "eol": e.get("eol", "N/A"), + "release_date": e.get("releaseDate", "N/A"), + } + ) + json.dump(result, sys.stdout, indent=2) + print() + return + + print("=== GKE Version Support (endoflife.date) ===") + print("Supported minor versions (newest first):") + print(f" {'VERSION':<8s} {'STATUS':<12s} {'END OF SUPPORT':<18s} {'RELEASE DATE':<18s}") + print(f" {'-------':<8s} {'------':<12s} {'--------------':<18s} {'------------':<18s}") + for e in supported: + ver = e["cycle"] + status = get_status(e, today) + eol = str(e.get("eol", "N/A")) + rel = str(e.get("releaseDate", "N/A")) + print(f" {ver:<8s} {status:<12s} {eol:<18s} {rel:<18s}") + + print() + print("NOTE: GKE uses a long-running static cluster. Version is NOT managed in CI config.") + print(" Updates require manual intervention on the cluster itself.") + + +if __name__ == "__main__": + main() diff --git a/skills/lifecycle/scripts/check_lifecycle.py b/skills/lifecycle/scripts/check_lifecycle.py new file mode 100644 index 0000000..a1be3e9 --- /dev/null +++ b/skills/lifecycle/scripts/check_lifecycle.py @@ -0,0 +1,122 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = [] +# /// +"""Check lifecycle status for any Red Hat product. + +Usage: + check_lifecycle.py --product rhbk + check_lifecycle.py --product quay --active-only + check_lifecycle.py --product rhbk --group-major + check_lifecycle.py --product "Red Hat Quay" --version 3.15 + check_lifecycle.py --list-products +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from rhdh_lifecycle.redhat import ( + fetch_product_lifecycle, + list_known_products, + resolve_product_name, + rhbk_major_versions, +) + + +def print_version_table(versions): + """Print a human-readable version lifecycle table.""" + print(f"{'VERSION':<10s} {'SUPPORTED':<10s} {'TYPE':<25s} {'GA_DATE':<12s} {'END_DATE':<12s}") + print(f"{'-------':<10s} {'---------':<10s} {'----':<25s} {'-------':<12s} {'--------':<12s}") + for v in versions: + sup = "yes" if v["supported"] else "no" + print( + f"{v['version']:<10s} {sup:<10s} {v['type']:<25s} " + f"{v['ga_date']:<12s} {v['end_date']:<12s}" + ) + + +def print_major_table(majors): + """Print RHBK major version grouping table.""" + print(f"{'MAJOR':<8s} {'ACTIVE':<8s} {'GA_DATE':<12s} {'END_DATE':<12s} MINOR_RELEASES") + print(f"{'-----':<8s} {'------':<8s} {'-------':<12s} {'--------':<12s} --------------") + for m in majors: + active = "yes" if m["active"] else "no" + minors = ", ".join(m["minor_releases"]) + print( + f"{m['major_version']:<8s} {active:<8s} {m['ga_date']:<12s} " + f"{m['end_date']:<12s} {minors}" + ) + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Check lifecycle status for any Red Hat product.") + parser.add_argument( + "--product", + "-p", + help="Product alias (rhbk, quay, rhdh, ocp) or full name", + ) + parser.add_argument("--version", "-v", help="Filter to a specific version") + parser.add_argument( + "--group-major", + action="store_true", + help="Group minor versions into major version summaries (useful for RHBK)", + ) + parser.add_argument("--active-only", action="store_true", help="Show only active versions") + parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") + parser.add_argument("--list-products", action="store_true", help="List known product aliases") + args = parser.parse_args(argv) + + if args.list_products: + if args.json_output: + json.dump(dict(list_known_products()), sys.stdout, indent=2) + print() + else: + print(f"{'ALIAS':<8s} PRODUCT NAME") + print(f"{'-----':<8s} ------------") + for alias, name in list_known_products(): + print(f"{alias:<8s} {name}") + return + + if not args.product: + parser.error("--product is required (or use --list-products)") + + full_name = resolve_product_name(args.product) + versions = fetch_product_lifecycle(args.product, args.version) + + if args.version and not versions: + print(f"ERROR: Version '{args.version}' not found for {full_name}", file=sys.stderr) + sys.exit(1) + + if args.active_only: + versions = [v for v in versions if v["supported"]] + + if args.group_major: + majors = rhbk_major_versions(versions) + if args.active_only: + majors = [m for m in majors if m["active"]] + if args.json_output: + json.dump({"product": full_name, "major_versions": majors}, sys.stdout, indent=2) + print() + else: + print(f"=== {full_name} (major versions) ===") + print() + print_major_table(majors) + return + + if args.json_output: + json.dump({"product": full_name, "versions": versions}, sys.stdout, indent=2) + print() + return + + print(f"=== {full_name} ===") + print() + print_version_table(versions) + print() + + +if __name__ == "__main__": + main() diff --git a/skills/lifecycle/scripts/check_ocp_lifecycle.py b/skills/lifecycle/scripts/check_ocp_lifecycle.py new file mode 100644 index 0000000..7e5a896 --- /dev/null +++ b/skills/lifecycle/scripts/check_ocp_lifecycle.py @@ -0,0 +1,95 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = [] +# /// +"""Check OCP version lifecycle status using the Red Hat Product Life Cycles API. + +Shows OCP version support phases (Full, Maintenance, EUS, EOL) and +cross-references with RHDH compatibility. + +Usage: + check_ocp_lifecycle.py # Show all OCP versions + check_ocp_lifecycle.py --version 4.16 # Check a specific OCP version + check_ocp_lifecycle.py --json # Output as JSON +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone + +from rhdh_lifecycle.ocp import classify_ocp_versions +from rhdh_lifecycle.rhdh import ( + fetch_lifecycle_api, + parse_rhdh_versions, + rhdh_supported_ocp_versions, +) + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Check OCP version lifecycle status.") + parser.add_argument("--version", "-v", help="Check a specific OCP version (e.g., 4.16)") + parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") + args = parser.parse_args(argv) + + now = datetime.now(timezone.utc) + today = now.strftime("%Y-%m-%d") + + # Fetch OCP lifecycle data + ocp_response = fetch_lifecycle_api("Red Hat OpenShift Container Platform") + if ocp_response is None: + print("ERROR: Failed to fetch OCP lifecycle data", file=sys.stderr) + sys.exit(1) + ocp_data = classify_ocp_versions(ocp_response, today) + + if args.version: + ocp_data = [v for v in ocp_data if v["version"] == args.version] + if not ocp_data: + print(f"ERROR: OCP version '{args.version}' not found", file=sys.stderr) + sys.exit(1) + + # Fetch RHDH data for the RHDH_SUPP cross-reference column + rhdh_response = fetch_lifecycle_api("Red Hat Developer Hub") + if rhdh_response is None: + print("ERROR: Failed to fetch RHDH lifecycle data", file=sys.stderr) + sys.exit(1) + rhdh_data = parse_rhdh_versions(rhdh_response) + supported_ocp = rhdh_supported_ocp_versions(rhdh_data) + + if args.json_output: + for v in ocp_data: + v["rhdh_supported"] = v["version"] in supported_ocp + output = { + "checked_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "versions": ocp_data, + "rhdh_supported_ocp": supported_ocp, + } + json.dump(output, sys.stdout, indent=2) + print() + return + + print("=== OCP Lifecycle ===") + print() + print( + f"{'VERSION':<10s} {'OCP_SUPP':<10s} {'RHDH_SUPP':<10s} " + f"{'PHASE':<35s} {'GA_DATE':<12s} {'END_DATE':<12s}" + ) + print( + f"{'-------':<10s} {'--------':<10s} {'---------':<10s} " + f"{'-----':<35s} {'-------':<12s} {'--------':<12s}" + ) + for v in ocp_data: + ocp_sup = "yes" if v["ocp_supported"] else "no" + rhdh_sup = "yes" if v["version"] in supported_ocp else "no" + print( + f"{v['version']:<10s} {ocp_sup:<10s} {rhdh_sup:<10s} " + f"{v['phase']:<35s} {v['ga_date']:<12s} {v['end_of_support_date']:<12s}" + ) + print() + + +if __name__ == "__main__": + main() diff --git a/skills/lifecycle/scripts/check_pg_lifecycle.py b/skills/lifecycle/scripts/check_pg_lifecycle.py new file mode 100644 index 0000000..2f27296 --- /dev/null +++ b/skills/lifecycle/scripts/check_pg_lifecycle.py @@ -0,0 +1,63 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = [] +# /// +"""Check PostgreSQL version lifecycle across cloud providers. + +Usage: + check_pg_lifecycle.py # Show all versions + check_pg_lifecycle.py --active-only # Show only supported versions + check_pg_lifecycle.py --json # Output as JSON +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone + +from rhdh_lifecycle.pg import fetch_pg_lifecycle + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check PostgreSQL version lifecycle across cloud providers." + ) + parser.add_argument("--active-only", action="store_true", help="Show only supported versions") + parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") + args = parser.parse_args(argv) + + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + versions = fetch_pg_lifecycle(today) + + if args.active_only: + versions = [v for v in versions if v["any_supported"]] + + if args.json_output: + json.dump({"checked_at": today, "versions": versions}, sys.stdout, indent=2) + print() + return + + print("=== PostgreSQL Version Lifecycle ===") + print() + print( + f" {'VERSION':<8s} {'SUPPORTED':<10s} {'UPSTREAM_EOL':<14s} " + f"{'RDS_EOL':<14s} {'AZURE_EOL':<14s} {'RELEASE':<12s}" + ) + print( + f" {'-------':<8s} {'---------':<10s} {'------------':<14s} " + f"{'-------':<14s} {'---------':<14s} {'-------':<12s}" + ) + for v in versions: + sup = "yes" if v["any_supported"] else "no" + print( + f" {v['major_version']:<8s} {sup:<10s} {v['upstream_eol']:<14s} " + f"{v['rds_eol']:<14s} {v['azure_eol']:<14s} {v['upstream_release']:<12s}" + ) + print() + + +if __name__ == "__main__": + main() diff --git a/skills/lifecycle/scripts/check_rhdh_lifecycle.py b/skills/lifecycle/scripts/check_rhdh_lifecycle.py new file mode 100644 index 0000000..f37398e --- /dev/null +++ b/skills/lifecycle/scripts/check_rhdh_lifecycle.py @@ -0,0 +1,85 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = [] +# /// +"""Check RHDH release lifecycle status using the Red Hat Product Life Cycles API. + +Usage: + check_rhdh_lifecycle.py # Show all RHDH releases + check_rhdh_lifecycle.py --version 1.9 # Check a specific version + check_rhdh_lifecycle.py --active-only # Show only active releases + check_rhdh_lifecycle.py --json # Output as JSON +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone + +from rhdh_lifecycle.rhdh import fetch_rhdh_lifecycle, rhdh_supported_ocp_versions + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Check RHDH release lifecycle status.") + parser.add_argument("--version", "-v", help="Check a specific RHDH version (e.g., 1.9)") + parser.add_argument( + "--active-only", action="store_true", help="Show only active (supported) releases" + ) + parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") + args = parser.parse_args(argv) + + now = datetime.now(timezone.utc) + rhdh_data = fetch_rhdh_lifecycle(args.version) + + if args.version and not rhdh_data: + print(f"ERROR: RHDH version '{args.version}' not found", file=sys.stderr) + sys.exit(1) + + if args.active_only: + rhdh_data = [v for v in rhdh_data if v["supported"]] + + if args.json_output: + output = { + "checked_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "versions": rhdh_data, + "ocp_versions_supported": rhdh_supported_ocp_versions(rhdh_data), + } + json.dump(output, sys.stdout, indent=2) + print() + return + + print("=== RHDH Lifecycle ===") + print() + print( + f"{'VERSION':<10s} {'SUPPORTED':<10s} {'TYPE':<22s} {'GA_DATE':<12s} " + f"{'FULL_SUPPORT_END':<25s} {'MAINTENANCE_END':<25s} SUPPORTED_OCP" + ) + print( + f"{'-------':<10s} {'---------':<10s} {'----':<22s} {'-------':<12s} " + f"{'----------------':<25s} {'---------------':<25s} -------------" + ) + for v in rhdh_data: + sup = "yes" if v["supported"] else "no" + ocp = ", ".join(v["ocp_versions"]) + print( + f"{v['version']:<10s} {sup:<10s} {v['type']:<22s} {v['ga_date']:<12s} " + f"{v['full_support_end']:<25s} {v['maintenance_end']:<25s} {ocp}" + ) + print() + + supported_ocp = rhdh_supported_ocp_versions(rhdh_data) + if supported_ocp: + print(f"OCP versions supported by active RHDH releases: {' '.join(supported_ocp)}") + print() + print("Per-release OCP support:") + for v in rhdh_data: + if v["supported"]: + print(f" RHDH {v['version']}: {', '.join(v['ocp_versions'])}") + print() + + +if __name__ == "__main__": + main() diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/__init__.py b/skills/lifecycle/scripts/rhdh_lifecycle/__init__.py new file mode 100644 index 0000000..c89e31f --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/__init__.py @@ -0,0 +1 @@ +"""RHDH lifecycle data and openshift/release CI config access.""" diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/configured_versions.py b/skills/lifecycle/scripts/rhdh_lifecycle/configured_versions.py new file mode 100644 index 0000000..053924e --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/configured_versions.py @@ -0,0 +1,65 @@ +"""Print configured MAPT_KUBERNETES_VERSION per branch from CI config files. + +Usage: + from rhdh_lifecycle.configured_versions import print_configured_versions + from rhdh_lifecycle.repo import resolve_repo_root + + root, is_remote = resolve_repo_root() + print_configured_versions(config_dir, test_pattern, root, is_remote) +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from rhdh_lifecycle.repo import resolve_repo_root # noqa: F401 +from rhdh_lifecycle.yaml import extract_branch, fetch_yaml, fetch_yaml_text, list_yaml_files + + +def print_configured_versions( + config_dir: str, + test_pattern: str, + root: Path | None, + is_remote: bool, + mapt_ref: str | None = None, +) -> None: + """Print configured MAPT_KUBERNETES_VERSION per branch. + + Shared helper used by lifecycle-aks and lifecycle-eks scripts. + """ + mapt_tag = "" + if mapt_ref: + ref_path = mapt_ref if is_remote else str(root / mapt_ref) if root else mapt_ref + text = fetch_yaml_text(ref_path, root, is_remote) + if text: + for line in text.splitlines(): + if "tag:" in line: + parts = line.split() + if len(parts) >= 2: + mapt_tag = parts[1] + break + + prefix = "redhat-developer-rhdh-" + files = list_yaml_files(config_dir, f"{prefix}*.yaml", root, is_remote) + if not files: + return + + pattern_re = re.compile(test_pattern) + print("Configured MAPT_KUBERNETES_VERSION per branch:") + for filepath in files: + branch = extract_branch(prefix, filepath) + data = fetch_yaml(filepath, root, is_remote) + if not data or "tests" not in data: + continue + versions = set() + for test in data["tests"]: + name = test.get("as", "") + if pattern_re.search(name): + ver = test.get("steps", {}).get("env", {}).get("MAPT_KUBERNETES_VERSION", "N/A") + versions.add(ver) + ver_str = ",".join(sorted(versions)) if versions else "N/A" + print(f" {branch}: {ver_str}") + if mapt_tag: + print(f"MAPT image: mapt:{mapt_tag}") + print() diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/ocp.py b/skills/lifecycle/scripts/rhdh_lifecycle/ocp.py new file mode 100644 index 0000000..b346270 --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/ocp.py @@ -0,0 +1,104 @@ +"""OCP lifecycle phase classification. + +Classifies OCP versions from the Red Hat Product Life Cycles API into +lifecycle phases (Full Support, Maintenance, EUS, End of life). + +Usage: + from rhdh_lifecycle.ocp import classify_ocp_versions + from rhdh_lifecycle.redhat import fetch_api + api_data = fetch_api("Red Hat OpenShift Container Platform") + versions = classify_ocp_versions(api_data, "2025-05-13") +""" + +import re + +from rhdh_lifecycle.utils import is_date, to_date, ver_sort_key + + +def classify_ocp_versions(api_data, today): + """Classify OCP versions from the Red Hat Product Life Cycles API. + + Args: + api_data: Raw API response dict (the full JSON response). + today: Date string in YYYY-MM-DD format. + + Returns: + List of dicts with keys: version, ocp_supported, phase, + ga_date, end_of_support_date. Sorted by version. + """ + data_list = api_data.get("data", []) + if not data_list: + return [] + versions = data_list[0].get("versions", []) + + # Keep only clean X.Y version names, skip variants like "4.6 EUS" or "3" + versions = [v for v in versions if re.match(r"^\d+\.\d+$", v.get("name", ""))] + + # Filter to OCP 4.x and above (future-proof for 5.x+) + versions = [v for v in versions if int(v["name"].split(".")[0]) >= 4] + + phase_order = [ + "Extended update support Term 2", + "Extended update support", + "Maintenance support", + "Full support", + ] + + results = [] + for ver in versions: + phases = ver.get("phases", []) + + # Find latest end-of-support date across all support phases + end_dates = [] + for pname in phase_order: + for p in phases: + if p.get("name") == pname: + d = to_date(p.get("end_date")) + if d: + end_dates.append(d) + end_of_support = max(end_dates) if end_dates else None + + # GA date + ga_raw = None + for p in phases: + if p.get("name") == "General availability": + ga_raw = p.get("end_date", "N/A") + break + + # Determine current phase + current_phase = "End of life" + for pname in phase_order: + for p in phases: + if p.get("name") != pname: + continue + start = to_date(p.get("start_date")) + end_raw = p.get("end_date") + end = to_date(end_raw) + if start and start <= today: + if end and end >= today: + current_phase = pname + break + elif not is_date(end_raw) and end_raw not in ( + "N/A", + "", + None, + ): + # Non-date end value (e.g., "Ongoing") means still active + current_phase = pname + break + if current_phase != "End of life": + break + + results.append( + { + "version": ver["name"], + "ocp_supported": current_phase != "End of life", + "phase": current_phase, + "ga_date": to_date(ga_raw) if is_date(ga_raw) else "N/A", + "end_of_support_date": end_of_support or "N/A", + } + ) + + # Sort by version + results.sort(key=lambda v: ver_sort_key(v["version"])) + return results diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/pg.py b/skills/lifecycle/scripts/rhdh_lifecycle/pg.py new file mode 100644 index 0000000..b429537 --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/pg.py @@ -0,0 +1,95 @@ +"""PostgreSQL lifecycle data from endoflife.date. + +Aggregates PostgreSQL version lifecycle from three providers: + - upstream PostgreSQL (endoflife.date/api/postgresql.json) + - Amazon RDS for PostgreSQL (endoflife.date/api/amazon-rds-postgresql.json) + - Azure Database for PostgreSQL (endoflife.date/api/azure-database-for-postgresql.json) + +Usage: + from rhdh_lifecycle.pg import fetch_pg_lifecycle + versions = fetch_pg_lifecycle() +""" + +from __future__ import annotations + +from rhdh_lifecycle.utils import fetch_json + +PROVIDERS = { + "upstream": "https://endoflife.date/api/postgresql.json", + "rds": "https://endoflife.date/api/amazon-rds-postgresql.json", + "azure": "https://endoflife.date/api/azure-database-for-postgresql.json", +} + + +def _normalize_eol(val): + """Normalize EOL value to a date string or 'N/A'.""" + if val is None or val == "N/A": + return "N/A" + if isinstance(val, bool): + return "N/A" if val else "active" + return str(val) + + +def fetch_pg_lifecycle(today=None): + """Fetch PostgreSQL lifecycle data from all providers. + + Returns a list of dicts per major version: + {major_version, upstream_eol, rds_eol, azure_eol, + upstream_release, any_supported} + + any_supported is True if the version is not EOL on at least one provider. + """ + # Fetch all providers + provider_data = {} + for provider, url in PROVIDERS.items(): + data = fetch_json(url) + if data: + provider_data[provider] = {str(e["cycle"]): e for e in data} + else: + provider_data[provider] = {} + + # Collect all major versions across providers + all_versions = set() + for pdata in provider_data.values(): + all_versions.update(pdata.keys()) + + # Filter to numeric major versions only + all_versions = {v for v in all_versions if v.isdigit()} + + results = [] + for ver in sorted(all_versions, key=int): + upstream = provider_data.get("upstream", {}).get(ver, {}) + rds = provider_data.get("rds", {}).get(ver, {}) + azure = provider_data.get("azure", {}).get(ver, {}) + + upstream_eol = _normalize_eol(upstream.get("eol")) + rds_eol = _normalize_eol(rds.get("eol")) + azure_eol = _normalize_eol(azure.get("eol")) + + # Check if any provider still supports this version + any_supported = False + if today: + for eol in [upstream_eol, rds_eol, azure_eol]: + if eol == "active": + any_supported = True + elif eol != "N/A" and eol > today: + any_supported = True + else: + # Without a date, just check if any EOL is in the future or unknown + for eol in [upstream_eol, rds_eol, azure_eol]: + if eol in ("active", "N/A"): + any_supported = True + break + + results.append( + { + "major_version": ver, + "upstream_eol": upstream_eol, + "rds_eol": rds_eol, + "azure_eol": azure_eol, + "upstream_release": upstream.get("releaseDate", "N/A"), + "any_supported": any_supported, + } + ) + + return results diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/redhat.py b/skills/lifecycle/scripts/rhdh_lifecycle/redhat.py new file mode 100644 index 0000000..b22924a --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/redhat.py @@ -0,0 +1,190 @@ +"""Unified client for the Red Hat Product Life Cycles API. + +Fetches lifecycle data for any Red Hat product (RHDH, OCP, RHBK, Quay, etc.) +and returns a consistent structure. Product-specific post-processing functions +handle cases like RHBK major version grouping or RHDH OCP compatibility. + +Usage: + from rhdh_lifecycle.redhat import fetch_product_lifecycle + + versions = fetch_product_lifecycle("rhbk") + versions = fetch_product_lifecycle("Red Hat Quay") + versions = fetch_product_lifecycle("ocp", filter_version="4.16") +""" + +from __future__ import annotations + +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + +from rhdh_lifecycle.utils import is_date, to_date, ver_sort_key # noqa: F401 + +LIFECYCLE_API_URL = "https://access.redhat.com/product-life-cycles/api/v1/products" + +PRODUCT_ALIASES = { + "rhdh": "Red Hat Developer Hub", + "ocp": "Red Hat OpenShift Container Platform", + "rhbk": "Red Hat build of Keycloak", + "quay": "Red Hat Quay", + "rosa": "Red Hat OpenShift Service on AWS", + "osd": "Red Hat OpenShift Dedicated", +} + + +def _phase_date(phases, phase_name): + """Extract the end_date for a named phase, formatted as YYYY-MM-DD or raw string.""" + for p in phases: + if p.get("name") == phase_name: + d = p.get("end_date", "N/A") + if d and isinstance(d, str) and is_date(d): + return d[:10] + return str(d) if d else "N/A" + return "N/A" + + +def resolve_product_name(product): + """Resolve a product alias to the full API product name.""" + return PRODUCT_ALIASES.get(product.lower(), product) + + +def fetch_api(product_name): + """Fetch raw lifecycle data from the Red Hat Product Life Cycles API. + + Returns the parsed JSON response, or None on failure. + """ + url = f"{LIFECYCLE_API_URL}?name={urllib.parse.quote_plus(product_name)}" + req = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": "rhdh-skill"} + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch lifecycle data for {product_name}: {exc}", file=sys.stderr) + return None + + +def parse_versions(api_data, filter_version=None): + """Parse raw API response into a consistent list of version dicts. + + Returns a list of dicts with keys: version, type, supported, ga_date, + end_date, phases (dict of phase_name -> end_date), extra (dict for + product-specific fields like ocp_versions). + """ + data_list = api_data.get("data", []) + if not data_list: + return [] + versions_raw = data_list[0].get("versions", []) + + results = [] + for ver in versions_raw: + name = ver.get("name", "") + if filter_version and name != filter_version: + continue + vtype = ver.get("type", "") + raw_phases = ver.get("phases", []) + + # Build phases dict + phases = {} + for p in raw_phases: + pname = p.get("name", "") + if pname: + phases[pname] = _phase_date(raw_phases, pname) + + # GA date + ga_date = phases.get("General availability", "N/A") + + # Latest end-of-support date across all non-GA phases + end_dates = [to_date(d) for d in phases.values() if to_date(d) and d != ga_date] + end_date = max(end_dates) if end_dates else "N/A" + + # Product-specific extra fields + extra = {} + ocp_compat = ver.get("openshift_compatibility", "") + if ocp_compat: + extra["ocp_versions"] = [v.strip() for v in ocp_compat.split(",") if v.strip()] + + results.append( + { + "version": name, + "type": vtype, + "supported": vtype != "End of life", + "ga_date": ga_date, + "end_date": end_date, + "phases": phases, + "extra": extra, + } + ) + + results.sort(key=lambda v: ver_sort_key(v["version"])) + return results + + +def fetch_product_lifecycle(product, filter_version=None): + """Fetch and parse lifecycle data for a Red Hat product. + + Args: + product: Product alias ("rhbk", "quay", "rhdh", "ocp") or full name. + filter_version: Optional version string to filter to. + + Returns: + List of version dicts with consistent shape. + """ + full_name = resolve_product_name(product) + api_data = fetch_api(full_name) + if api_data is None: + return [] + return parse_versions(api_data, filter_version) + + +def rhbk_major_versions(versions): + """Group RHBK minor versions into major version summaries. + + A major version is "active" if at least one of its minor releases + is not end-of-life. + + Returns: + List of dicts: {major_version, active, ga_date, end_date, minor_releases} + """ + groups = {} + for v in versions: + # Skip umbrella entries like "26.x" + if "x" in v["version"] or not re.match(r"^\d+\.\d+$", v["version"]): + continue + major = v["version"].split(".")[0] + if major not in groups: + groups[major] = { + "minor_releases": [], + "any_active": False, + "ga_dates": [], + "end_dates": [], + } + groups[major]["minor_releases"].append(v["version"]) + if v["supported"]: + groups[major]["any_active"] = True + if v["ga_date"] != "N/A": + groups[major]["ga_dates"].append(v["ga_date"]) + if v["end_date"] != "N/A": + groups[major]["end_dates"].append(v["end_date"]) + + results = [] + for major, info in sorted(groups.items(), key=lambda x: int(x[0])): + results.append( + { + "major_version": major, + "active": info["any_active"], + "ga_date": min(info["ga_dates"]) if info["ga_dates"] else "N/A", + "end_date": max(info["end_dates"]) if info["end_dates"] else "N/A", + "minor_releases": sorted(info["minor_releases"], key=ver_sort_key), + } + ) + return results + + +def list_known_products(): + """Return sorted list of known product aliases and their full names.""" + return sorted(PRODUCT_ALIASES.items()) diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/repo.py b/skills/lifecycle/scripts/rhdh_lifecycle/repo.py new file mode 100644 index 0000000..903e9df --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/repo.py @@ -0,0 +1,63 @@ +"""Resolve the openshift/release repository root for local or remote access. + +Resolution order: + 1. Explicit path passed via resolve_repo_root(explicit_dir=...) + 2. OPENSHIFT_RELEASE_DIR environment variable + 3. Walk up from cwd looking for the ci-operator sentinel directory + 4. Fall back to REMOTE mode (GitHub API via gh CLI) + +Usage (in consuming scripts): + from rhdh_lifecycle.repo import resolve_repo_root + root, is_remote = resolve_repo_root() +""" + +import os +import sys +from pathlib import Path + +# Sentinel path that identifies an openshift/release checkout. +_SENTINEL = Path("ci-operator/config/redhat-developer/rhdh") + +# GitHub repository for remote access. +GITHUB_REPO = os.environ.get("OPENSHIFT_RELEASE_REPO", "openshift/release") + + +def resolve_repo_root(explicit_dir=None): + """Return (root_path, is_remote). + + root_path is a Path when local, None when remote. + is_remote is True when no local checkout was found. + """ + # 1. Explicit override + if explicit_dir is not None: + p = Path(explicit_dir) + if (p / _SENTINEL).is_dir(): + return p.resolve(), False + print( + f"WARNING: explicit dir {explicit_dir} does not contain {_SENTINEL}", + file=sys.stderr, + ) + + # 2. Environment variable + env_dir = os.environ.get("OPENSHIFT_RELEASE_DIR") + if env_dir: + p = Path(env_dir) + if (p / _SENTINEL).is_dir(): + return p.resolve(), False + print( + f"WARNING: OPENSHIFT_RELEASE_DIR is set but {_SENTINEL} not found there", + file=sys.stderr, + ) + + # 3. Walk up from cwd + cur = Path.cwd() + while True: + if (cur / _SENTINEL).is_dir(): + return cur.resolve(), False + parent = cur.parent + if parent == cur: + break + cur = parent + + # 4. Remote mode + return None, True diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/rhdh.py b/skills/lifecycle/scripts/rhdh_lifecycle/rhdh.py new file mode 100644 index 0000000..ec3c6bc --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/rhdh.py @@ -0,0 +1,47 @@ +"""RHDH release lifecycle data -- wrapper around the generic Red Hat API client. + +Usage: + from rhdh_lifecycle.rhdh import fetch_rhdh_lifecycle + versions = fetch_rhdh_lifecycle() +""" + +from __future__ import annotations + +from rhdh_lifecycle.redhat import fetch_api, fetch_product_lifecycle, parse_versions +from rhdh_lifecycle.utils import ver_sort_key + + +def _enrich_rhdh_version(v): + """Flatten RHDH-specific fields into top-level for convenience.""" + v["ocp_versions"] = v.get("extra", {}).get("ocp_versions", []) + v["full_support_end"] = v.get("phases", {}).get("Full support", "N/A") + v["maintenance_end"] = v.get("phases", {}).get("Maintenance support", "N/A") + + +def fetch_rhdh_lifecycle(filter_version=None): + """Fetch and parse RHDH lifecycle data.""" + versions = fetch_product_lifecycle("rhdh", filter_version) + for v in versions: + _enrich_rhdh_version(v) + return versions + + +def fetch_lifecycle_api(product_name): + """Fetch raw API data. Delegates to redhat module.""" + return fetch_api(product_name) + + +def parse_rhdh_versions(api_data, filter_version=None): + """Parse RHDH versions from raw API data.""" + versions = parse_versions(api_data, filter_version) + for v in versions: + _enrich_rhdh_version(v) + return versions + + +def rhdh_supported_ocp_versions(rhdh_data): + """Return sorted list of OCP versions supported by any active RHDH release.""" + return sorted( + {ocp for v in rhdh_data if v["supported"] for ocp in v.get("ocp_versions", [])}, + key=ver_sort_key, + ) diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/utils.py b/skills/lifecycle/scripts/rhdh_lifecycle/utils.py new file mode 100644 index 0000000..d02ac02 --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/utils.py @@ -0,0 +1,78 @@ +"""Generic utility functions shared across lifecycle scripts. + +Provides URL fetching, date parsing, version sorting, and endoflife.date +helpers used by all lifecycle and prow scripts. + +Usage: + from rhdh_lifecycle.utils import fetch_json, ver_sort_key +""" + +from __future__ import annotations + +import json +import re +import sys +import urllib.error +import urllib.request + + +def fetch_json(url): + """Fetch JSON from a URL. + + Returns the parsed JSON, or None on failure. Shared by all lifecycle + scripts that consume external APIs (endoflife.date, AKS, EKS, etc.). + """ + req = urllib.request.Request(url, headers={"User-Agent": "rhdh-skill"}) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch {url}: {exc}", file=sys.stderr) + return None + + +def is_date(val): + """Return True if val looks like a YYYY-MM-DD date string.""" + if not val or not isinstance(val, str): + return False + return bool(re.match(r"^\d{4}-\d{2}-\d{2}", val)) + + +def to_date(val): + """Extract YYYY-MM-DD from a date string, or None.""" + if is_date(val): + return val[:10] + return None + + +def ver_sort_key(version_str): + """Sort key for version strings like '4.16' or '26.2'.""" + try: + return [int(x) for x in version_str.split(".")] + except ValueError: + return [0] + + +def filter_supported_eol_entries(eol_data, today): + """Filter endoflife.date entries to those still supported. + + Considers both ``eol`` and ``extendedSupport`` fields. Returns the + filtered list sorted by cycle version (newest first). + """ + supported = [] + for entry in eol_data: + eol = entry.get("eol", "N/A") + ext = entry.get("extendedSupport", "N/A") + has_support = False + if eol == "N/A": + has_support = True + elif isinstance(eol, bool): + has_support = not eol + elif isinstance(eol, str) and eol > today: + has_support = True + if not has_support and isinstance(ext, str) and ext > today: + has_support = True + if has_support: + supported.append(entry) + supported.sort(key=lambda e: ver_sort_key(e["cycle"]), reverse=True) + return supported diff --git a/skills/lifecycle/scripts/rhdh_lifecycle/yaml.py b/skills/lifecycle/scripts/rhdh_lifecycle/yaml.py new file mode 100644 index 0000000..3669b90 --- /dev/null +++ b/skills/lifecycle/scripts/rhdh_lifecycle/yaml.py @@ -0,0 +1,116 @@ +"""Fetch and parse YAML files from the openshift/release repository. + +Supports both local checkout and remote GitHub API access. Provides +helper functions for listing files, reading YAML, and extracting +configured K8s versions from CI config files. + +Usage as a library (imported by other scripts): + from rhdh_lifecycle.yaml import list_yaml_files, fetch_yaml, extract_branch +""" + +from __future__ import annotations + +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +from ruamel.yaml import YAML + +from rhdh_lifecycle.repo import GITHUB_REPO + +_yaml = YAML() +_yaml.preserve_quotes = True + + +def list_yaml_files(config_dir: str, pattern: str, root: Path | None, is_remote: bool) -> list[str]: + """List YAML files in a directory matching a glob pattern. + + In local mode, returns absolute path strings. + In remote mode, returns repo-relative path strings. + """ + if is_remote: + api_path = f"repos/{GITHUB_REPO}/contents/{config_dir}" + try: + result = subprocess.run( + ["gh", "api", api_path, "--jq", ".[] | .path"], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + print(f"ERROR: Failed to list {config_dir} via GitHub API: {exc}", file=sys.stderr) + return [] + # Filter by pattern (convert glob to regex) + regex = re.compile(pattern.replace("*", ".*").replace("?", ".")) + return [ + line for line in result.stdout.strip().splitlines() if regex.search(Path(line).name) + ] + else: + local_dir = root / config_dir if root else Path(config_dir) + if not local_dir.is_dir(): + print(f"ERROR: Directory not found: {local_dir}", file=sys.stderr) + return [] + return sorted(str(f) for f in local_dir.glob(pattern) if f.is_file()) + + +def fetch_yaml(filepath: str, root: Path | None, is_remote: bool) -> dict | None: + """Read and parse a single YAML file. + + Returns the parsed YAML as a dict, or None on failure. + """ + if is_remote: + raw_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/HEAD/{filepath}" + try: + req = urllib.request.Request(raw_url, headers={"User-Agent": "rhdh-skill"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return _yaml.load(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch {filepath}: {exc}", file=sys.stderr) + return None + else: + path = Path(filepath) + if not path.is_file(): + print(f"ERROR: File not found: {filepath}", file=sys.stderr) + return None + with open(path) as fh: + return _yaml.load(fh) + + +def fetch_yaml_text(filepath: str, root: Path | None, is_remote: bool) -> str | None: + """Read a file as raw text (no YAML parsing). + + Useful for files that need grep-style processing rather than structured + parsing (e.g., extracting a tag value from a ref YAML). + """ + if is_remote: + raw_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/HEAD/{filepath}" + try: + req = urllib.request.Request(raw_url, headers={"User-Agent": "rhdh-skill"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read().decode("utf-8") + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch {filepath}: {exc}", file=sys.stderr) + return None + else: + path = Path(filepath) + if not path.is_file(): + print(f"ERROR: File not found: {filepath}", file=sys.stderr) + return None + return path.read_text() + + +def extract_branch(prefix: str, filepath: str) -> str: + """Extract the branch/filename stem from a config file path. + + Example: + extract_branch("redhat-developer-rhdh-", ".../redhat-developer-rhdh-main.yaml") + => "main" + """ + name = Path(filepath).stem # removes .yaml + if name.startswith(prefix): + return name[len(prefix) :] + return name diff --git a/skills/lifecycle/workflows/check-aks.md b/skills/lifecycle/workflows/check-aks.md new file mode 100644 index 0000000..da6be4f --- /dev/null +++ b/skills/lifecycle/workflows/check-aks.md @@ -0,0 +1,29 @@ +# Check AKS Kubernetes Version Lifecycle + +Query the AKS release status API and cross-verify with endoflife.date. + +## Run + +```bash +uv run scripts/check_aks_lifecycle.py \ + --test-pattern "^e2e-aks-" \ + --mapt-ref ci-operator/step-registry/redhat-developer/rhdh/aks/mapt/create/redhat-developer-rhdh-aks-mapt-create-ref.yaml +``` + +Override repo location with `--repo-dir /path/to/openshift/release`. + +## Output + +1. **Configured MAPT_KUBERNETES_VERSION per branch** -- what each RHDH release branch is using +2. **AKS Release Status** -- supported versions marked GA, LTS, or Preview +3. **Cross-verify (endoflife.date)** -- independent EOL dates + +## Action + +**Always update the main branch to the newest GA version.** For release branches, ask the user before updating. + +If the API call fails, fall back to the vendor docs: + +```text +WebFetch https://learn.microsoft.com/en-us/azure/aks/supported-kubernetes-versions +``` diff --git a/skills/lifecycle/workflows/check-eks.md b/skills/lifecycle/workflows/check-eks.md new file mode 100644 index 0000000..84fc607 --- /dev/null +++ b/skills/lifecycle/workflows/check-eks.md @@ -0,0 +1,30 @@ +# Check EKS Kubernetes Version Lifecycle + +Query the AWS EKS docs source and cross-verify with endoflife.date. + +## Run + +```bash +uv run scripts/check_eks_lifecycle.py \ + --test-pattern "^e2e-eks-" \ + --mapt-ref ci-operator/step-registry/redhat-developer/rhdh/eks/mapt/create/redhat-developer-rhdh-eks-mapt-create-ref.yaml +``` + +Override repo location with `--repo-dir /path/to/openshift/release`. + +## Output + +1. **Configured MAPT_KUBERNETES_VERSION per branch** -- what each RHDH release branch is using +2. **Supported minor versions** -- Standard or Extended support tier +3. **Release calendar** -- upstream release, EKS release, end dates +4. **Cross-verify (endoflife.date)** -- independent EOL and extended support dates + +## Action + +**Always update the main branch to the newest Standard version.** Prefer Standard over Extended to avoid extra costs. For release branches, ask the user before updating. + +If the API call fails, fall back to the vendor docs: + +```text +WebFetch https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions.html +``` diff --git a/skills/lifecycle/workflows/check-gke.md b/skills/lifecycle/workflows/check-gke.md new file mode 100644 index 0000000..239eb93 --- /dev/null +++ b/skills/lifecycle/workflows/check-gke.md @@ -0,0 +1,26 @@ +# Check GKE Kubernetes Version Lifecycle + +GKE uses a pre-existing long-running cluster. The K8s version is NOT in CI config -- updates are performed via the GCP Console. + +## Run + +```bash +uv run scripts/check_gke_lifecycle.py +``` + +## Output + +Supported GKE K8s versions with their support status and dates: + +- **Standard**: actively supported, receives regular patches and security updates +- **Maintenance**: past standard support, still receives critical security patches + +## Action + +**Always recommend upgrading to the newest Standard version.** Check the actual cluster version via the [GKE clusters page](https://console.cloud.google.com/kubernetes/list/overview). + +If the API call fails, fall back to the vendor docs: + +```text +WebFetch https://cloud.google.com/kubernetes-engine/docs/release-schedule +``` diff --git a/skills/lifecycle/workflows/check-ocp.md b/skills/lifecycle/workflows/check-ocp.md new file mode 100644 index 0000000..9ca541c --- /dev/null +++ b/skills/lifecycle/workflows/check-ocp.md @@ -0,0 +1,39 @@ +# Check OCP Version Lifecycle + +Query the Red Hat Product Life Cycles API for OCP version support status. + +## Run + +```bash +uv run scripts/check_ocp_lifecycle.py +``` + +### Check a specific version + +```bash +uv run scripts/check_ocp_lifecycle.py --version 4.16 +``` + +## Output + +| Column | Description | +|--------|-------------| +| VERSION | OCP minor version (e.g., `4.16`) | +| OCP_SUPP | `yes` if OCP has upstream support (any phase) | +| RHDH_SUPP | `yes` if any active RHDH release supports this OCP version | +| PHASE | Current lifecycle phase (Full support, Maintenance, EUS, End of life) | +| GA_DATE | General Availability date | +| END_DATE | Latest end-of-support date across all phases | + +The **RHDH_SUPP** column is the key indicator for CI coverage decisions. + +## Action + +Report the results. If a version is OCP-supported but not RHDH-supported (`RHDH_SUPP = no`), flag it — this may indicate a CI coverage gap or a version that RHDH has intentionally dropped. Refer to the `prow` skill for CI job management if updates are needed. + +## Key Concepts + +- **Full Support**: Actively supported, receives patches and security updates +- **Maintenance Support**: Past full support, still receives critical fixes +- **Extended Update Support (EUS)**: Extended lifecycle for specific versions +- An OCP version can be OCP-supported but not RHDH-supported (e.g., an older EUS version that RHDH has dropped) diff --git a/skills/lifecycle/workflows/check-pg.md b/skills/lifecycle/workflows/check-pg.md new file mode 100644 index 0000000..568d8c4 --- /dev/null +++ b/skills/lifecycle/workflows/check-pg.md @@ -0,0 +1,36 @@ +# Check PostgreSQL Version Lifecycle + +Aggregates PostgreSQL lifecycle data from three providers via endoflife.date: + +- **Upstream PostgreSQL** -- community support EOL dates +- **Amazon RDS for PostgreSQL** -- AWS RDS support EOL dates +- **Azure Database for PostgreSQL** -- Azure Flexible Server support EOL dates + +## Run + +```bash +uv run scripts/check_pg_lifecycle.py +``` + +### Show only supported versions + +```bash +uv run scripts/check_pg_lifecycle.py --active-only +``` + +## Output + +| Column | Description | +|--------|-------------| +| VERSION | PostgreSQL major version (e.g., `16`) | +| SUPPORTED | `yes` if supported by at least one provider | +| UPSTREAM_EOL | Community PostgreSQL end-of-life date | +| RDS_EOL | Amazon RDS end-of-support date | +| AZURE_EOL | Azure Database end-of-support date | +| RELEASE | Upstream release date | + +## Action + +A PostgreSQL version should be removed from RHDH test coverage when **all three providers** have reached EOL. If only one or two providers have EOL'd but others still support it, keep the version. + +For new deployments, recommend the newest major version that is supported by all three providers. diff --git a/skills/lifecycle/workflows/check-redhat.md b/skills/lifecycle/workflows/check-redhat.md new file mode 100644 index 0000000..7531c24 --- /dev/null +++ b/skills/lifecycle/workflows/check-redhat.md @@ -0,0 +1,63 @@ +# Check Red Hat Product Lifecycle + +Query the Red Hat Product Life Cycles API for any Red Hat product. + +## Run + +### Check a product by alias + +```bash +uv run scripts/check_lifecycle.py --product rhbk +uv run scripts/check_lifecycle.py --product quay +``` + +### RHBK major version grouping + +```bash +uv run scripts/check_lifecycle.py --product rhbk --group-major +``` + +### Show only active versions + +```bash +uv run scripts/check_lifecycle.py --product quay --active-only +``` + +### List known aliases + +```bash +uv run scripts/check_lifecycle.py --list-products +``` + +## Known Aliases + +| Alias | Full Product Name | +|-------|-------------------| +| `rhdh` | Red Hat Developer Hub | +| `ocp` | Red Hat OpenShift Container Platform | +| `rhbk` | Red Hat build of Keycloak | +| `quay` | Red Hat Quay | +| `rosa` | Red Hat OpenShift Service on AWS | +| `osd` | Red Hat OpenShift Dedicated | + +Any product name not in the alias list is passed to the API as-is. + +## Output + +| Column | Description | +|--------|-------------| +| VERSION | Product version (e.g., `26.2`, `3.15`) | +| SUPPORTED | `yes` if the version is still active | +| TYPE | Lifecycle type from the API (e.g., `Full Support`, `End of life`) | +| GA_DATE | General Availability date | +| END_DATE | Latest end-of-support date across all phases | + +With `--group-major` (RHBK): groups minor releases under major version summaries. + +## Action + +Report the results. If the user asked about a specific product version, highlight whether it is still supported and when support ends. No automated updates — this is informational only. + +## RHBK Note + +Track **major versions only** (e.g., `26`). A major version is active if at least one of its minor releases is still supported. Use `--group-major` to see the summary. diff --git a/skills/lifecycle/workflows/check-rhdh.md b/skills/lifecycle/workflows/check-rhdh.md new file mode 100644 index 0000000..b085ed7 --- /dev/null +++ b/skills/lifecycle/workflows/check-rhdh.md @@ -0,0 +1,39 @@ +# Check RHDH Release Lifecycle + +Query the Red Hat Product Life Cycles API for RHDH release information. + +## Run + +```bash +uv run scripts/check_rhdh_lifecycle.py +``` + +### Check a specific version + +```bash +uv run scripts/check_rhdh_lifecycle.py --version 1.9 +``` + +### Show only active releases + +```bash +uv run scripts/check_rhdh_lifecycle.py --active-only +``` + +## Output + +| Column | Description | +|--------|-------------| +| VERSION | RHDH release version (e.g., `1.9`) | +| SUPPORTED | `yes` or `no` | +| TYPE | `Full Support`, `Maintenance Support`, or `End of life` | +| GA_DATE | General Availability date | +| FULL_SUPPORT_END | End of Full Support phase | +| MAINTENANCE_END | End of Maintenance Support phase | +| SUPPORTED_OCP | OCP versions this RHDH release officially supports | + +The `openshift_compatibility` field in the API is the authoritative source for which OCP versions each RHDH release supports. + +## Action + +Report the results. Use the per-release OCP support breakdown to identify which OCP versions are covered by active RHDH releases. This is the primary input for CI coverage decisions — compare with `check-ocp.md` output to spot gaps. diff --git a/skills/prow/SKILL.md b/skills/prow/SKILL.md new file mode 100644 index 0000000..0009a7e --- /dev/null +++ b/skills/prow/SKILL.md @@ -0,0 +1,67 @@ +--- +name: prow +description: >- + Manage Prow CI job configurations for RHDH in the openshift/release + repository. List, generate, add, and remove OCP test entries and cluster + pools. List K8s platform test entries (AKS, EKS, GKE). Analyze OCP + version coverage gaps. Decommission end-of-life release branches. Use + when working with RHDH CI config, Prow jobs, cluster pools, or + openshift/release CI management. +--- +# RHDH Prow CI Management + +Manage Prow CI job configurations for RHDH in the `openshift/release` repository. + +## Prerequisites + +- Python 3.9+ +- For listing: works from any directory (auto-detects local checkout or uses GitHub API) +- For generating/modifying: requires a local `openshift/release` checkout + +## Important: Branch Terminology + +**"Branch" refers to the RHDH product branch** encoded in the config filename (e.g., `main`, `release-1.8`), **NOT** a git branch in `openshift/release`. All CI config files live on the `main` git branch. + +## Identify Task + +What CI management task do you need? + +| Query matches | Workflow | +|---|---| +| "OCP test", "OCP job", "e2e-ocp", "add OCP version", "new OCP test" | `workflows/ocp-jobs.md` | +| "cluster pool", "ClusterPool", "Hive pool" | `workflows/ocp-pools.md` | +| "coverage", "gap analysis", "what OCP versions are missing" | `workflows/ocp-coverage.md` | +| "AKS test", "EKS test", "GKE test", "K8s platform jobs" | `workflows/k8s-jobs.md` | +| "decommission", "EOL release", "remove release branch", "clean up old release" | `workflows/decommission-release.md` | + +After reading the workflow, follow it exactly. + +## Available Scripts + +All listing scripts support `--repo-dir` to override the openshift/release location and work in both local and remote (GitHub API) modes. + +| Script | Purpose | +|--------|---------| +| `scripts/list_ocp_test_configs.py` | List OCP test entries per branch | +| `scripts/generate_test_entry.py` | Generate a new OCP test entry YAML block | +| `scripts/list_cluster_pools.py` | List RHDH Hive ClusterPool configurations | +| `scripts/generate_cluster_pool.py` | Generate a new ClusterPool YAML file | +| `scripts/analyze_coverage.py` | Cross-reference coverage against lifecycle data | +| `scripts/list_aks_jobs.py` | List AKS test entries | +| `scripts/list_eks_jobs.py` | List EKS test entries | +| `scripts/list_gke_jobs.py` | List GKE test entries | + +## After Any Change + +Always run `make update` after modifying CI config files: + +```bash +make update +``` + +This regenerates Prow job configs in `ci-operator/jobs/` and `zz_generated_metadata` sections. + +## Related Skills + +- **`lifecycle`**: Provides repo resolution, YAML I/O, and lifecycle data. + The `rhdh_prow` package delegates to `rhdh_lifecycle` for shared utilities. diff --git a/skills/prow/scripts/analyze_coverage.py b/skills/prow/scripts/analyze_coverage.py new file mode 100644 index 0000000..1b7ac27 --- /dev/null +++ b/skills/prow/scripts/analyze_coverage.py @@ -0,0 +1,454 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""Analyze RHDH OCP version coverage. + +Cross-references cluster pools, CI test configs, RHDH lifecycle, and OCP +lifecycle data to identify coverage gaps and stale configurations. + +Two dimensions are checked: + 1. OCP lifecycle -- is the OCP version itself still supported? + 2. RHDH compatibility -- does RHDH officially list this OCP version? +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from rhdh_prow.repo import resolve_repo_root +from rhdh_prow.utils import ver_sort_key +from rhdh_prow.yaml import extract_branch, fetch_yaml, list_yaml_files + +POOL_DIR = "clusters/hosted-mgmt/hive/pools/rhdh" +CI_CONFIG_DIR = "ci-operator/config/redhat-developer/rhdh" +LIFECYCLE_API_URL = "https://access.redhat.com/product-life-cycles/api/v1/products" + + +def _fetch_lifecycle_json(script_name): + """Run a lifecycle script with --json and return parsed output. + + Falls back to direct API call if the script is not available. + """ + lifecycle_dir = Path(__file__).resolve().parent.parent.parent / "lifecycle" / "scripts" + script = lifecycle_dir / script_name + if script.exists(): + try: + result = subprocess.run( + ["uv", "run", str(script), "--json"], + capture_output=True, + text=True, + check=True, + timeout=60, + ) + return json.loads(result.stdout) + except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError): + pass + return None + + +def _fetch_api(product_name): + """Fetch lifecycle data directly from the Red Hat API (fallback).""" + url = f"{LIFECYCLE_API_URL}?name={product_name.replace(' ', '+')}" + req = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": "rhdh-skill"} + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch lifecycle data for {product_name}: {exc}", file=sys.stderr) + return None + + +def _get_rhdh_lifecycle(): + """Get RHDH lifecycle data via subprocess or direct API call.""" + # Try subprocess first + data = _fetch_lifecycle_json("check_rhdh_lifecycle.py") + if data and "versions" in data: + return data["versions"] + + # Fallback: direct API call + minimal parsing + api_data = _fetch_api("Red Hat Developer Hub") + if not api_data: + return [] + versions_raw = api_data.get("data", [{}])[0].get("versions", []) + results = [] + for ver in versions_raw: + ocp_compat = ver.get("openshift_compatibility", "") + ocp_versions = [v.strip() for v in ocp_compat.split(",") if v.strip()] if ocp_compat else [] + results.append( + { + "version": ver.get("name", ""), + "type": ver.get("type", ""), + "supported": ver.get("type", "") != "End of life", + "ocp_versions": ocp_versions, + } + ) + results.sort(key=lambda v: ver_sort_key(v["version"]) if "." in v["version"] else [0]) + return results + + +def _get_ocp_lifecycle(today): + """Get OCP lifecycle data via subprocess or direct API call.""" + import re + + # Try subprocess first + data = _fetch_lifecycle_json("check_ocp_lifecycle.py") + if data and "versions" in data: + return data["versions"] + + # Fallback: direct API call + phase classification + api_data = _fetch_api("Red Hat OpenShift Container Platform") + if not api_data: + return [] + + def _is_date(val): + return bool(val and isinstance(val, str) and re.match(r"^\d{4}-\d{2}-\d{2}", val)) + + def _to_date(val): + return val[:10] if _is_date(val) else None + + versions = api_data.get("data", [{}])[0].get("versions", []) + versions = [v for v in versions if re.match(r"^\d+\.\d+$", v.get("name", ""))] + versions = [v for v in versions if int(v["name"].split(".")[0]) >= 4] + + phase_order = [ + "Extended update support Term 2", + "Extended update support", + "Maintenance support", + "Full support", + ] + results = [] + for ver in versions: + phases = ver.get("phases", []) + end_dates = [] + for pname in phase_order: + for p in phases: + if p.get("name") == pname: + d = _to_date(p.get("end_date")) + if d: + end_dates.append(d) + + current_phase = "End of life" + for pname in phase_order: + for p in phases: + if p.get("name") != pname: + continue + start = _to_date(p.get("start_date")) + end_raw = p.get("end_date") + end = _to_date(end_raw) + if start and start <= today: + if end and end >= today: + current_phase = pname + break + elif not _is_date(end_raw) and end_raw not in ("N/A", "", None): + current_phase = pname + break + if current_phase != "End of life": + break + results.append( + { + "version": ver["name"], + "ocp_supported": current_phase != "End of life", + "phase": current_phase, + } + ) + results.sort(key=lambda v: ver_sort_key(v["version"])) + return results + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Analyze RHDH OCP version coverage.") + parser.add_argument("--pool-dir", default=POOL_DIR, help="Pool directory") + parser.add_argument("--config-dir", default=CI_CONFIG_DIR, help="CI config directory") + parser.add_argument("--repo-dir", help="Path to openshift/release checkout") + args = parser.parse_args(argv) + + root, is_remote = resolve_repo_root(args.repo_dir) + now = datetime.now(timezone.utc) + today = now.strftime("%Y-%m-%d") + mode_desc = "remote (GitHub API)" if is_remote else "local" + + print("=" * 56) + print(" RHDH OCP Coverage Analysis") + print("=" * 56) + print() + print(f"Pool directory: {args.pool_dir}") + print(f"Config directory: {args.config_dir}") + print(f"Access mode: {mode_desc}") + print(f"Analysis time: {now.strftime('%Y-%m-%dT%H:%M:%SZ')}") + print() + + # ------- 1. Cluster pool versions ------- + print("--- Cluster Pools ---") + pool_versions = [] + pool_files = list_yaml_files(args.pool_dir, "*_clusterpool.yaml", root, is_remote) + for filepath in pool_files: + data = fetch_yaml(filepath, root, is_remote) + if not data: + continue + ver = data.get("metadata", {}).get("labels", {}).get("version") + if not ver: + continue + pool_name = data.get("metadata", {}).get("name", "unknown") + size = data.get("spec", {}).get("size", 0) + max_size = data.get("spec", {}).get("maxSize", 0) + pool_versions.append(ver) + print(f" {ver:<8s} {pool_name:<25s} size={size} max={max_size}") + print() + + # ------- 2. Test config versions per branch ------- + print("--- Test Configs ---") + prefix = "redhat-developer-rhdh-" + branch_versions: dict[str, list[str]] = {} + all_test_versions: list[str] = [] + + config_files = list_yaml_files(args.config_dir, f"{prefix}*.yaml", root, is_remote) + for filepath in config_files: + branch = extract_branch(prefix, filepath) + data = fetch_yaml(filepath, root, is_remote) + if not data or "tests" not in data: + continue + versions = sorted( + { + t["cluster_claim"]["version"] + for t in data["tests"] + if t.get("cluster_claim", {}).get("version") + }, + key=ver_sort_key, + ) + if versions: + branch_versions[branch] = versions + all_test_versions.extend(versions) + print(f" {branch}: {' '.join(versions)}") + print() + + unique_test_versions = sorted(set(all_test_versions), key=ver_sort_key) + + # ------- 3. RHDH lifecycle ------- + print("--- RHDH Lifecycle ---") + print(" Fetching lifecycle data...") + rhdh_data = _get_rhdh_lifecycle() + if not rhdh_data: + print(" ERROR: Failed to fetch RHDH lifecycle data", file=sys.stderr) + sys.exit(1) + + for v in rhdh_data: + if v["supported"]: + print( + f" RHDH {v['version']} ({v['type']}): OCP {', '.join(v.get('ocp_versions', []))}" + ) + + rhdh_supported_ocp = sorted( + {ocp for v in rhdh_data if v["supported"] for ocp in v.get("ocp_versions", [])}, + key=ver_sort_key, + ) + print() + print(f" OCP versions supported by active RHDH releases: {' '.join(rhdh_supported_ocp)}") + print() + + # Build per-RHDH-release -> OCP version mapping + rhdh_branch_ocp: dict[str, list[str]] = {} + latest_rhdh_ocp: list[str] = [] + for v in rhdh_data: + if v["supported"]: + branch = f"release-{v['version']}" + rhdh_branch_ocp[branch] = v["ocp_versions"] + latest_rhdh_ocp = v["ocp_versions"] + + # ------- 4. OCP lifecycle ------- + print("--- OCP Lifecycle ---") + print(" Fetching lifecycle data...") + ocp_lifecycle = _get_ocp_lifecycle(today) + if not ocp_lifecycle: + print(" ERROR: Failed to fetch OCP lifecycle data", file=sys.stderr) + sys.exit(1) + + ocp_supported = [v["version"] for v in ocp_lifecycle if v["ocp_supported"]] + ocp_eol = [v["version"] for v in ocp_lifecycle if not v["ocp_supported"]] + print(f" OCP supported: {' '.join(ocp_supported)}") + print(f" OCP end-of-life: {' '.join(ocp_eol)}") + print() + + # Compute "main" branch OCP support + if latest_rhdh_ocp: + max_rhdh = max(latest_rhdh_ocp, key=ver_sort_key) + max_parts = ver_sort_key(max_rhdh) + main_ocp = list(latest_rhdh_ocp) + for ocp_ver in ocp_supported: + ocp_parts = ver_sort_key(ocp_ver) + if ocp_parts > max_parts and ocp_ver not in main_ocp: + main_ocp.append(ocp_ver) + rhdh_branch_ocp["main"] = sorted(main_ocp, key=ver_sort_key) + + # ------- 5. OCP version matrix ------- + print("--- OCP Version Matrix ---") + print() + print( + f" {'OCP':<8s} {'OCP_SUPP':<10s} {'RHDH_SUPP':<10s} {'OCP_PHASE':<30s} RHDH_RELEASES" + ) + print( + f" {'---':<8s} {'--------':<10s} {'---------':<10s} {'---------':<30s} -------------" + ) + + all_relevant = sorted( + set(pool_versions + unique_test_versions + rhdh_supported_ocp + ocp_supported), + key=ver_sort_key, + ) + + ocp_phase_map = {v["version"]: v for v in ocp_lifecycle} + for ver in all_relevant: + ocp_info = ocp_phase_map.get(ver, {}) + ocp_sup = "yes" if ocp_info.get("ocp_supported") else "no" + ocp_phase = ocp_info.get("phase", "N/A") + rhdh_sup = "yes" if ver in rhdh_supported_ocp else "no" + rhdh_releases = ", ".join( + v["version"] for v in rhdh_data if v["supported"] and ver in v["ocp_versions"] + ) + print(f" {ver:<8s} {ocp_sup:<10s} {rhdh_sup:<10s} {ocp_phase:<30s} {rhdh_releases}") + print() + + # ------- 6. Cross-reference analysis ------- + print("=" * 56) + print(" Analysis Results") + print("=" * 56) + print() + + has_actions = False + eol_pool_count = 0 + notrhdh_pool_count = 0 + mismatch_test_count = 0 + missing_pool_count = 0 + missing_test_count = 0 + + # 6a. Pools for OCP-EOL versions + print("--- Pools for OCP-EOL Versions (REMOVE) ---") + for ver in pool_versions: + if ver in ocp_eol: + print(f" REMOVE pool: {ver} (OCP end-of-life)") + eol_pool_count += 1 + has_actions = True + if eol_pool_count == 0: + print(" (none)") + print() + + # 6b. Pools for non-RHDH-supported versions + print("--- Pools for Non-RHDH-Supported OCP Versions (REVIEW) ---") + for ver in pool_versions: + if ver in ocp_eol: + continue + if ver not in rhdh_supported_ocp: + print(f" REVIEW pool: {ver} (OCP supported, but not in any active RHDH release)") + notrhdh_pool_count += 1 + has_actions = True + if notrhdh_pool_count == 0: + print(" (none)") + print() + + # 6c. Test entries mismatched with RHDH compatibility + print("--- Test Entries Mismatched With RHDH Compatibility (REVIEW) ---") + for branch, versions in branch_versions.items(): + branch_ocp = rhdh_branch_ocp.get(branch, []) + for ver in versions: + if ver in ocp_eol: + print(f" REMOVE test: {ver} from {branch} (OCP end-of-life)") + mismatch_test_count += 1 + has_actions = True + elif branch_ocp and ver not in branch_ocp: + print(f" REVIEW test: {ver} in {branch} (not in RHDH openshift_compatibility)") + mismatch_test_count += 1 + has_actions = True + if mismatch_test_count == 0: + print(" (none)") + print() + + # 6d. Missing pools + print("--- RHDH-Supported OCP Versions Missing Pools (ADD) ---") + all_rhdh_ocp = sorted( + {ver for ocp_list in rhdh_branch_ocp.values() for ver in ocp_list}, + key=ver_sort_key, + ) + for ver in all_rhdh_ocp: + if ver in ocp_eol: + continue + if ver not in pool_versions: + needed = ", ".join( + v["version"] for v in rhdh_data if v["supported"] and ver in v["ocp_versions"] + ) + print(f" ADD pool: {ver} (needed by RHDH {needed})") + missing_pool_count += 1 + has_actions = True + if missing_pool_count == 0: + print(" (none)") + print() + + # 6e. Missing tests + print("--- RHDH-Supported OCP Versions Missing Tests (ADD) ---") + for branch, ocp_list in rhdh_branch_ocp.items(): + existing = branch_versions.get(branch, []) + for ver in ocp_list: + if ver in ocp_eol: + continue + if ver not in existing: + print(f" ADD test: {ver} to {branch}") + missing_test_count += 1 + has_actions = True + if missing_test_count == 0: + print(" (none)") + print() + + # ------- 7. Summary ------- + print("=" * 56) + print(" Summary") + print("=" * 56) + print() + print(f" Pool versions: {' '.join(pool_versions)}") + print(f" Test versions: {' '.join(unique_test_versions)}") + print(f" OCP supported: {' '.join(ocp_supported)}") + print(f" RHDH-supported OCP: {' '.join(rhdh_supported_ocp)}") + print() + + print(" RHDH branch -> OCP support (excluding OCP-EOL):") + for branch in sorted(rhdh_branch_ocp.keys()): + active = [v for v in rhdh_branch_ocp[branch] if v not in ocp_eol] + eol_listed = [v for v in rhdh_branch_ocp[branch] if v in ocp_eol] + line = f" {branch}: {' '.join(active)}" + if eol_listed: + line += f" (RHDH lists but OCP-EOL: {' '.join(eol_listed)})" + print(line) + print() + + print(f" EOL pools to remove: {eol_pool_count}") + print(f" Non-RHDH pools to review: {notrhdh_pool_count}") + print(f" Mismatched tests to review: {mismatch_test_count}") + print(f" Missing pools to add: {missing_pool_count}") + print(f" Missing tests to add: {missing_test_count}") + print() + + if has_actions: + print(" Data sources:") + print(" RHDH lifecycle: https://access.redhat.com/support/policy/updates/developerhub") + print( + " OCP lifecycle: https://access.redhat.com/product-life-cycles/" + "?product=OpenShift+Container+Platform+4" + ) + print() + print(" NOTE: The 'main' branch targets the next unreleased RHDH version.") + print(" Its OCP support is estimated as: latest RHDH release's OCP list") + print(" plus any newer OCP versions that have reached GA.") + print(" REVIEW items require judgment; REMOVE/ADD items are actionable.") + else: + print(" All clear -- no coverage gaps or stale configurations found.") + + +if __name__ == "__main__": + main() diff --git a/skills/prow/scripts/generate_cluster_pool.py b/skills/prow/scripts/generate_cluster_pool.py new file mode 100644 index 0000000..1454dc8 --- /dev/null +++ b/skills/prow/scripts/generate_cluster_pool.py @@ -0,0 +1,151 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""Generate a new RHDH Hive ClusterPool YAML for a target OCP version. + +The imageSetRef is looked up from existing cluster pools across the entire +openshift/release repository (not just RHDH pools) to ensure alignment. +If no pool for the target version exists anywhere in the repo, the script +errors out rather than guessing a patch version. + +Requires a local openshift/release checkout (writes files). +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from ruamel.yaml import YAML + +POOL_DIR = "clusters/hosted-mgmt/hive/pools/rhdh" +ALL_POOLS_DIR = "clusters/hosted-mgmt/hive/pools" + +_yaml = YAML() +_yaml.preserve_quotes = True + + +def find_image_set_ref(all_pools_dir: Path, major: str, minor: str) -> str | None: + """Find imageSetRef for an OCP version by scanning ALL cluster pools.""" + pattern = f"ocp-release-{major}.{minor}." + matches = [] + for pool_file in all_pools_dir.rglob("*_clusterpool.yaml"): + try: + text = pool_file.read_text() + except OSError: + continue + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("name:") and pattern in stripped: + ref = stripped.split("name:", 1)[1].strip() + matches.append(ref) + if not matches: + return None + # Return the latest (highest patch version) + matches.sort() + return matches[-1] + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Generate a new RHDH Hive ClusterPool YAML.") + parser.add_argument("--version", "-v", required=True, help="Target OCP version (e.g., 4.22)") + parser.add_argument("--reference", "-r", help="Reference OCP version to use as template") + parser.add_argument("--pool-dir", "-d", default=POOL_DIR, help="RHDH pool directory") + parser.add_argument("--all-pools-dir", default=ALL_POOLS_DIR, help="All pools directory") + parser.add_argument("--dry-run", action="store_true", help="Preview without writing") + args = parser.parse_args(argv) + + if not re.match(r"^\d+\.\d+$", args.version): + print(f"ERROR: Version must be in X.Y format, got: {args.version}", file=sys.stderr) + sys.exit(1) + + major, minor = args.version.split(".") + next_minor = str(int(minor) + 1) + dash_ver = f"{major}-{minor}" + + pool_dir = Path(args.pool_dir) + all_pools_dir = Path(args.all_pools_dir) + + if not pool_dir.is_dir(): + print(f"ERROR: Pool directory not found: {pool_dir}", file=sys.stderr) + sys.exit(1) + + target_file = pool_dir / f"rhdh-ocp-{dash_ver}-0-amd64-aws-us-east-2_clusterpool.yaml" + if target_file.exists(): + print(f"ERROR: Cluster pool already exists: {target_file}", file=sys.stderr) + sys.exit(1) + + # 1. Find imageSetRef + print( + f"Looking up imageSetRef for OCP {args.version} across {all_pools_dir}/ ...", + file=sys.stderr, + ) + image_set_ref = find_image_set_ref(all_pools_dir, major, minor) + if not image_set_ref: + print(f"ERROR: No existing cluster pool uses OCP {args.version}.", file=sys.stderr) + print("Cannot determine the correct imageSetRef.", file=sys.stderr) + sys.exit(1) + print(f"Found imageSetRef: {image_set_ref}", file=sys.stderr) + + # 2. Find reference pool + if args.reference: + ref_major, ref_minor = args.reference.split(".") + ref_file = ( + pool_dir / f"rhdh-ocp-{ref_major}-{ref_minor}-0-amd64-aws-us-east-2_clusterpool.yaml" + ) + if not ref_file.exists(): + print(f"ERROR: Reference pool not found: {ref_file}", file=sys.stderr) + sys.exit(1) + else: + pool_files = sorted(pool_dir.glob("*_clusterpool.yaml")) + if not pool_files: + print(f"ERROR: No existing cluster pool files in {pool_dir}", file=sys.stderr) + sys.exit(1) + ref_file = pool_files[-1] + + print(f"Using reference pool: {ref_file.name}", file=sys.stderr) + + # 3. Generate new pool + with open(ref_file) as fh: + pool_data = _yaml.load(fh) + + # Update version-specific fields + pool_data["metadata"]["name"] = f"rhdh-ocp-{dash_ver}-0-amd64-aws-us-east-2" + pool_data["metadata"]["labels"]["version"] = args.version + pool_data["metadata"]["labels"]["version_lower"] = args.version + pool_data["metadata"]["labels"]["version_upper"] = f"{major}.{next_minor}" + pool_data["spec"]["imageSetRef"]["name"] = image_set_ref + pool_data["spec"]["size"] = 1 + pool_data["spec"]["maxSize"] = 2 + + # Remove runningCount if present (conservative sizing) + if "runningCount" in pool_data.get("spec", {}): + del pool_data["spec"]["runningCount"] + + # Write or preview + if args.dry_run: + _yaml.dump(pool_data, sys.stdout) + else: + with open(target_file, "w") as fh: + _yaml.dump(pool_data, fh) + print(f"\nGenerated: {target_file}", file=sys.stderr) + _yaml.dump(pool_data, sys.stdout) + + # Print summary + print(file=sys.stderr) + print("Fields updated:", file=sys.stderr) + print(f" metadata.name: {pool_data['metadata']['name']}", file=sys.stderr) + print(f" metadata.labels.version: {args.version}", file=sys.stderr) + print(f" metadata.labels.version_lower: {args.version}", file=sys.stderr) + print(f" metadata.labels.version_upper: {major}.{next_minor}", file=sys.stderr) + print(f" spec.imageSetRef.name: {image_set_ref}", file=sys.stderr) + print(" spec.size: 1", file=sys.stderr) + print(" spec.maxSize: 2", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/prow/scripts/generate_test_entry.py b/skills/prow/scripts/generate_test_entry.py new file mode 100644 index 0000000..c6da27e --- /dev/null +++ b/skills/prow/scripts/generate_test_entry.py @@ -0,0 +1,120 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""Generate a new e2e-ocp-vX-Y-helm-nightly test entry YAML block. + +Clones a reference entry and substitutes the OCP version in: + - as (test name) + - cluster_claim.version + - steps.env.OC_CLIENT_VERSION + +Outputs the block to stdout for review before insertion. +""" + +from __future__ import annotations + +import argparse +import copy +import re +import sys +from io import StringIO + +from rhdh_prow.repo import resolve_repo_root +from rhdh_prow.yaml import fetch_yaml, list_yaml_files +from ruamel.yaml import YAML + +CONFIG_DIR = "ci-operator/config/redhat-developer/rhdh" +PREFIX = "redhat-developer-rhdh-" + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Generate a new OCP test entry YAML block.") + parser.add_argument("--version", "-v", required=True, help="Target OCP version (e.g., 4.22)") + parser.add_argument("--branch", "-b", required=True, help="Product branch (e.g., main)") + parser.add_argument("--reference", "-r", help="Reference OCP version to clone from") + parser.add_argument("--config-dir", "-d", default=CONFIG_DIR, help="CI config directory") + parser.add_argument("--repo-dir", help="Path to openshift/release checkout") + args = parser.parse_args(argv) + + if not re.match(r"^\d+\.\d+$", args.version): + print(f"ERROR: Version must be in X.Y format, got: {args.version}", file=sys.stderr) + sys.exit(1) + + major, minor = args.version.split(".") + root, is_remote = resolve_repo_root(args.repo_dir) + + # Find the config file for the target branch + config_filename = f"{PREFIX}{args.branch}.yaml" + files = list_yaml_files(args.config_dir, config_filename, root, is_remote) + if not files: + print(f"ERROR: Config file not found for branch '{args.branch}'", file=sys.stderr) + sys.exit(1) + + data = fetch_yaml(files[0], root, is_remote) + if not data or "tests" not in data: + print(f"ERROR: Failed to read config for branch '{args.branch}'", file=sys.stderr) + sys.exit(1) + + new_name = f"e2e-ocp-v{major}-{minor}-helm-nightly" + + # Check if entry already exists + if any(t.get("as") == new_name for t in data["tests"]): + print(f"ERROR: Test entry {new_name} already exists in {args.branch}", file=sys.stderr) + sys.exit(1) + + # Find reference entry + if args.reference: + if not re.match(r"^\d+\.\d+$", args.reference): + print(f"ERROR: Reference must be in X.Y format, got: {args.reference}", file=sys.stderr) + sys.exit(1) + ref_major, ref_minor = args.reference.split(".") + ref_name = f"e2e-ocp-v{ref_major}-{ref_minor}-helm-nightly" + else: + # Find the latest versioned OCP helm-nightly entry + versioned = [ + t.get("as", "") + for t in data["tests"] + if re.match(r"^e2e-ocp-v\d+-\d+-helm-nightly$", t.get("as", "")) + ] + if not versioned: + print(f"ERROR: No reference OCP test entry found in {args.branch}", file=sys.stderr) + sys.exit(1) + ref_name = sorted(versioned)[-1] + + # Extract reference entry + ref_entry = None + for t in data["tests"]: + if t.get("as") == ref_name: + ref_entry = copy.deepcopy(t) + break + + if ref_entry is None: + print(f"ERROR: Reference entry '{ref_name}' not found", file=sys.stderr) + sys.exit(1) + + # Substitute version fields + ref_entry["as"] = new_name + if "cluster_claim" in ref_entry: + ref_entry["cluster_claim"]["version"] = args.version + if "steps" in ref_entry and "env" in ref_entry["steps"]: + ref_entry["steps"]["env"]["OC_CLIENT_VERSION"] = f"stable-{args.version}" + + # Output as YAML + print(f"# Generated test entry for OCP {args.version}", file=sys.stderr) + print(f"# Based on reference: {ref_name}", file=sys.stderr) + print(f"# Insert this block into the tests: list in {config_filename}", file=sys.stderr) + print("# Place it adjacent to other e2e-ocp-v*-helm-nightly entries", file=sys.stderr) + print("# Then run: make update", file=sys.stderr) + print(file=sys.stderr) + + yaml_out = YAML() + yaml_out.default_flow_style = False + buf = StringIO() + yaml_out.dump(dict(ref_entry), buf) + print(buf.getvalue()) + + +if __name__ == "__main__": + main() diff --git a/skills/prow/scripts/list_aks_jobs.py b/skills/prow/scripts/list_aks_jobs.py new file mode 100644 index 0000000..d4d0c7a --- /dev/null +++ b/skills/prow/scripts/list_aks_jobs.py @@ -0,0 +1,13 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""List AKS test entries in RHDH CI config files.""" + +import sys + +from rhdh_prow.k8s_configs import main + +if __name__ == "__main__": + main(["--pattern", "^e2e-aks-", *sys.argv[1:]]) diff --git a/skills/prow/scripts/list_cluster_pools.py b/skills/prow/scripts/list_cluster_pools.py new file mode 100644 index 0000000..ab96d95 --- /dev/null +++ b/skills/prow/scripts/list_cluster_pools.py @@ -0,0 +1,76 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""List RHDH Hive ClusterPool configurations.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from rhdh_prow.repo import resolve_repo_root +from rhdh_prow.utils import ver_sort_key +from rhdh_prow.yaml import fetch_yaml, list_yaml_files + +POOL_DIR = "clusters/hosted-mgmt/hive/pools/rhdh" + + +def main(argv=None): + parser = argparse.ArgumentParser(description="List RHDH Hive ClusterPool configurations.") + parser.add_argument("--pool-dir", "-d", default=POOL_DIR, help="Pool directory") + parser.add_argument("--repo-dir", help="Path to openshift/release checkout") + args = parser.parse_args(argv) + + root, is_remote = resolve_repo_root(args.repo_dir) + + print("=== RHDH Cluster Pools ===") + print() + print( + f" {'VERSION':<8s} {'POOL_NAME':<50s} {'SIZE':<5s} {'MAX':<5s} " + f"{'RUNNING':<8s} {'IMAGE_SET':<70s} FILENAME" + ) + print( + f" {'-------':<8s} {'---------':<50s} {'----':<5s} {'---':<5s} " + f"{'-------':<8s} {'---------':<70s} --------" + ) + + files = list_yaml_files(args.pool_dir, "*_clusterpool.yaml", root, is_remote) + if not files: + print("ERROR: No pool files found", file=sys.stderr) + sys.exit(1) + + rows = [] + for filepath in files: + data = fetch_yaml(filepath, root, is_remote) + if not data: + continue + + labels = data.get("metadata", {}).get("labels", {}) + ver = labels.get("version") + if not ver: + continue + + spec = data.get("spec", {}) + pool_name = data.get("metadata", {}).get("name", "unknown") + size = str(spec.get("size", 0)) + max_size = str(spec.get("maxSize", 0)) + running = str(spec.get("runningCount", 0)) + image_set = spec.get("imageSetRef", {}).get("name", "N/A") + filename = Path(filepath).name + + rows.append((ver, pool_name, size, max_size, running, image_set, filename)) + + # Sort by version + rows.sort(key=lambda r: ver_sort_key(r[0])) + for ver, pool_name, size, max_size, running, image_set, filename in rows: + print( + f" {ver:<8s} {pool_name:<50s} {size:<5s} {max_size:<5s} " + f"{running:<8s} {image_set:<70s} {filename}" + ) + + +if __name__ == "__main__": + main() diff --git a/skills/prow/scripts/list_eks_jobs.py b/skills/prow/scripts/list_eks_jobs.py new file mode 100644 index 0000000..8d161a5 --- /dev/null +++ b/skills/prow/scripts/list_eks_jobs.py @@ -0,0 +1,13 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""List EKS test entries in RHDH CI config files.""" + +import sys + +from rhdh_prow.k8s_configs import main + +if __name__ == "__main__": + main(["--pattern", "^e2e-eks-", *sys.argv[1:]]) diff --git a/skills/prow/scripts/list_gke_jobs.py b/skills/prow/scripts/list_gke_jobs.py new file mode 100644 index 0000000..d90fc83 --- /dev/null +++ b/skills/prow/scripts/list_gke_jobs.py @@ -0,0 +1,13 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""List GKE test entries in RHDH CI config files.""" + +import sys + +from rhdh_prow.k8s_configs import main + +if __name__ == "__main__": + main(["--pattern", "^e2e-gke-", *sys.argv[1:]]) diff --git a/skills/prow/scripts/list_ocp_test_configs.py b/skills/prow/scripts/list_ocp_test_configs.py new file mode 100644 index 0000000..7c1fde6 --- /dev/null +++ b/skills/prow/scripts/list_ocp_test_configs.py @@ -0,0 +1,77 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""List OCP versions used in RHDH CI test configs. + +Extracts OCP versions from cluster_claim.version (the source of truth), +not from test names. This catches all OCP-targeted tests, including ones +that don't encode the version in their name. +""" + +from __future__ import annotations + +import argparse +import sys + +from rhdh_prow.repo import resolve_repo_root +from rhdh_prow.utils import ver_sort_key +from rhdh_prow.yaml import extract_branch, fetch_yaml, list_yaml_files + +CONFIG_DIR = "ci-operator/config/redhat-developer/rhdh" +PREFIX = "redhat-developer-rhdh-" + + +def main(argv=None): + parser = argparse.ArgumentParser(description="List OCP test configs in RHDH CI config files.") + parser.add_argument("--branch", "-b", help="Filter by branch name") + parser.add_argument("--config-dir", "-d", default=CONFIG_DIR, help="CI config directory") + parser.add_argument("--repo-dir", help="Path to openshift/release checkout") + args = parser.parse_args(argv) + + root, is_remote = resolve_repo_root(args.repo_dir) + + files = list_yaml_files(args.config_dir, f"{PREFIX}*.yaml", root, is_remote) + if not files: + print("ERROR: No config files found", file=sys.stderr) + sys.exit(1) + + for filepath in files: + branch = extract_branch(PREFIX, filepath) + if args.branch and branch != args.branch: + continue + + data = fetch_yaml(filepath, root, is_remote) + if not data or "tests" not in data: + continue + + # Extract tests with cluster_claim.version (OCP tests) + entries = [t for t in data["tests"] if t.get("cluster_claim", {}).get("version")] + if not entries: + continue + + # Unique OCP versions + versions = sorted( + {t["cluster_claim"]["version"] for t in entries}, + key=ver_sort_key, + ) + + print() + print(f"=== Branch: {branch} ===") + print(f" {'TEST_NAME':<45s} {'OCP_VERSION':<13s} {'CRON':<30s} {'OPTIONAL':<10s}") + print(f" {'---------':<45s} {'-----------':<13s} {'----':<30s} {'--------':<10s}") + + for t in sorted(entries, key=lambda x: x.get("as", "")): + name = t.get("as", "") + ver = t["cluster_claim"]["version"] + cron = t.get("cron", "N/A") + opt = str(t.get("optional", False)).lower() + print(f" {name:<45s} {ver:<13s} {cron:<30s} {opt:<10s}") + + print() + print(f" OCP versions tested: {' '.join(versions)}") + + +if __name__ == "__main__": + main() diff --git a/skills/prow/scripts/rhdh_prow/__init__.py b/skills/prow/scripts/rhdh_prow/__init__.py new file mode 100644 index 0000000..a650056 --- /dev/null +++ b/skills/prow/scripts/rhdh_prow/__init__.py @@ -0,0 +1 @@ +"""Access openshift/release repo data (CI configs, cluster pools).""" diff --git a/skills/prow/scripts/rhdh_prow/k8s_configs.py b/skills/prow/scripts/rhdh_prow/k8s_configs.py new file mode 100644 index 0000000..52b3041 --- /dev/null +++ b/skills/prow/scripts/rhdh_prow/k8s_configs.py @@ -0,0 +1,102 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = ["ruamel.yaml"] +# /// +"""List K8s platform test entries in RHDH CI config files. + +Shared script used by prow-aks-jobs, prow-eks-jobs, and prow-gke-jobs. +Supports both local openshift/release checkout and remote GitHub API access. + +Usage: + list_k8s_test_configs.py --pattern "^e2e-aks-" + list_k8s_test_configs.py --pattern "^e2e-eks-" --branch main + list_k8s_test_configs.py --pattern "^e2e-gke-" +""" + +from __future__ import annotations + +import argparse +import re +import sys + +from rhdh_prow.repo import resolve_repo_root +from rhdh_prow.yaml import extract_branch, fetch_yaml, list_yaml_files + +CONFIG_DIR = "ci-operator/config/redhat-developer/rhdh" +PREFIX = "redhat-developer-rhdh-" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="List K8s platform test entries in RHDH CI config files." + ) + parser.add_argument( + "--pattern", "-p", required=True, help="Regex to match test names (e.g., '^e2e-aks-')" + ) + parser.add_argument("--branch", "-b", help="Filter by branch name") + parser.add_argument("--config-dir", "-d", default=CONFIG_DIR, help="CI config directory") + parser.add_argument("--repo-dir", help="Path to openshift/release checkout") + args = parser.parse_args(argv) + + root, is_remote = resolve_repo_root(args.repo_dir) + pattern_re = re.compile(args.pattern) + + files = list_yaml_files(args.config_dir, f"{PREFIX}*.yaml", root, is_remote) + if not files: + print("ERROR: No config files found", file=sys.stderr) + sys.exit(1) + + has_mapt_version = False + + for filepath in files: + branch = extract_branch(PREFIX, filepath) + if args.branch and branch != args.branch: + continue + + data = fetch_yaml(filepath, root, is_remote) + if not data or "tests" not in data: + continue + + entries = [t for t in data["tests"] if pattern_re.search(t.get("as", ""))] + if not entries: + continue + + # Check if any entry has MAPT_KUBERNETES_VERSION + has_ver = any( + t.get("steps", {}).get("env", {}).get("MAPT_KUBERNETES_VERSION") for t in entries + ) + + print() + print(f"=== Branch: {branch} ===") + if has_ver: + has_mapt_version = True + print(f" {'TEST_NAME':<40s} {'K8S_VERSION':<13s} {'CRON':<30s} {'OPTIONAL':<10s}") + print(f" {'---------':<40s} {'-----------':<13s} {'----':<30s} {'--------':<10s}") + for t in sorted(entries, key=lambda x: x.get("as", "")): + name = t.get("as", "") + ver = t.get("steps", {}).get("env", {}).get("MAPT_KUBERNETES_VERSION", "N/A") + cron = t.get("cron", "N/A") + opt = str(t.get("optional", False)).lower() + print(f" {name:<40s} {ver:<13s} {cron:<30s} {opt:<10s}") + else: + print(f" {'TEST_NAME':<40s} {'CRON':<30s} {'OPTIONAL':<10s}") + print(f" {'---------':<40s} {'----':<30s} {'--------':<10s}") + for t in sorted(entries, key=lambda x: x.get("as", "")): + name = t.get("as", "") + cron = t.get("cron", "N/A") + opt = str(t.get("optional", False)).lower() + print(f" {name:<40s} {cron:<30s} {opt:<10s}") + + print() + if has_mapt_version: + print( + "K8s version source: MAPT_KUBERNETES_VERSION in steps.env per test entry", + file=sys.stderr, + ) + else: + print("K8s version: managed outside CI config (static cluster)", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/prow/scripts/rhdh_prow/repo.py b/skills/prow/scripts/rhdh_prow/repo.py new file mode 100644 index 0000000..522cb1b --- /dev/null +++ b/skills/prow/scripts/rhdh_prow/repo.py @@ -0,0 +1,65 @@ +"""Resolve the openshift/release repository root for local or remote access. + +Resolution order: + 1. Explicit path passed via resolve_repo_root(explicit_dir=...) + 2. OPENSHIFT_RELEASE_DIR environment variable + 3. Walk up from cwd looking for the ci-operator sentinel directory + 4. Fall back to REMOTE mode (GitHub API via gh CLI) + +Usage (in consuming scripts): + from rhdh_prow.repo import resolve_repo_root + +NOTE: This file is a copy of rhdh_lifecycle/repo.py (lifecycle skill). + When modifying either copy, update both to keep them in sync. +""" + +import os +import sys +from pathlib import Path + +# Sentinel path that identifies an openshift/release checkout. +_SENTINEL = Path("ci-operator/config/redhat-developer/rhdh") + +# GitHub repository for remote access. +GITHUB_REPO = os.environ.get("OPENSHIFT_RELEASE_REPO", "openshift/release") + + +def resolve_repo_root(explicit_dir=None): + """Return (root_path, is_remote). + + root_path is a Path when local, None when remote. + is_remote is True when no local checkout was found. + """ + # 1. Explicit override + if explicit_dir is not None: + p = Path(explicit_dir) + if (p / _SENTINEL).is_dir(): + return p.resolve(), False + print( + f"WARNING: explicit dir {explicit_dir} does not contain {_SENTINEL}", + file=sys.stderr, + ) + + # 2. Environment variable + env_dir = os.environ.get("OPENSHIFT_RELEASE_DIR") + if env_dir: + p = Path(env_dir) + if (p / _SENTINEL).is_dir(): + return p.resolve(), False + print( + f"WARNING: OPENSHIFT_RELEASE_DIR is set but {_SENTINEL} not found there", + file=sys.stderr, + ) + + # 3. Walk up from cwd + cur = Path.cwd() + while True: + if (cur / _SENTINEL).is_dir(): + return cur.resolve(), False + parent = cur.parent + if parent == cur: + break + cur = parent + + # 4. Remote mode + return None, True diff --git a/skills/prow/scripts/rhdh_prow/utils.py b/skills/prow/scripts/rhdh_prow/utils.py new file mode 100644 index 0000000..9b7a337 --- /dev/null +++ b/skills/prow/scripts/rhdh_prow/utils.py @@ -0,0 +1,15 @@ +"""Generic utility functions shared across prow scripts. + +NOTE: This file is a subset of rhdh_lifecycle/utils.py (lifecycle skill). + When modifying either copy, update both to keep them in sync. +""" + +from __future__ import annotations + + +def ver_sort_key(version_str): + """Sort key for version strings like '4.16' or '26.2'.""" + try: + return [int(x) for x in version_str.split(".")] + except ValueError: + return [0] diff --git a/skills/prow/scripts/rhdh_prow/yaml.py b/skills/prow/scripts/rhdh_prow/yaml.py new file mode 100644 index 0000000..a61f377 --- /dev/null +++ b/skills/prow/scripts/rhdh_prow/yaml.py @@ -0,0 +1,119 @@ +"""Fetch and parse YAML files from the openshift/release repository. + +Supports both local checkout and remote GitHub API access. Provides +helper functions for listing files, reading YAML, and extracting +configured K8s versions from CI config files. + +Usage as a library (imported by other scripts): + from rhdh_prow.yaml import list_yaml_files, fetch_yaml, extract_branch + +NOTE: This file is a copy of rhdh_lifecycle/yaml.py (lifecycle skill). + When modifying either copy, update both to keep them in sync. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +from ruamel.yaml import YAML + +from rhdh_prow.repo import GITHUB_REPO + +_yaml = YAML() +_yaml.preserve_quotes = True + + +def list_yaml_files(config_dir: str, pattern: str, root: Path | None, is_remote: bool) -> list[str]: + """List YAML files in a directory matching a glob pattern. + + In local mode, returns absolute path strings. + In remote mode, returns repo-relative path strings. + """ + if is_remote: + api_path = f"repos/{GITHUB_REPO}/contents/{config_dir}" + try: + result = subprocess.run( + ["gh", "api", api_path, "--jq", ".[] | .path"], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + print(f"ERROR: Failed to list {config_dir} via GitHub API: {exc}", file=sys.stderr) + return [] + # Filter by pattern (convert glob to regex) + regex = re.compile(pattern.replace("*", ".*").replace("?", ".")) + return [ + line for line in result.stdout.strip().splitlines() if regex.search(Path(line).name) + ] + else: + local_dir = root / config_dir if root else Path(config_dir) + if not local_dir.is_dir(): + print(f"ERROR: Directory not found: {local_dir}", file=sys.stderr) + return [] + return sorted(str(f) for f in local_dir.glob(pattern) if f.is_file()) + + +def fetch_yaml(filepath: str, root: Path | None, is_remote: bool) -> dict | None: + """Read and parse a single YAML file. + + Returns the parsed YAML as a dict, or None on failure. + """ + if is_remote: + raw_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/HEAD/{filepath}" + try: + req = urllib.request.Request(raw_url, headers={"User-Agent": "rhdh-skill"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return _yaml.load(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch {filepath}: {exc}", file=sys.stderr) + return None + else: + path = Path(filepath) + if not path.is_file(): + print(f"ERROR: File not found: {filepath}", file=sys.stderr) + return None + with open(path) as fh: + return _yaml.load(fh) + + +def fetch_yaml_text(filepath: str, root: Path | None, is_remote: bool) -> str | None: + """Read a file as raw text (no YAML parsing). + + Useful for files that need grep-style processing rather than structured + parsing (e.g., extracting a tag value from a ref YAML). + """ + if is_remote: + raw_url = f"https://raw.githubusercontent.com/{GITHUB_REPO}/HEAD/{filepath}" + try: + req = urllib.request.Request(raw_url, headers={"User-Agent": "rhdh-skill"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read().decode("utf-8") + except (urllib.error.URLError, OSError) as exc: + print(f"ERROR: Failed to fetch {filepath}: {exc}", file=sys.stderr) + return None + else: + path = Path(filepath) + if not path.is_file(): + print(f"ERROR: File not found: {filepath}", file=sys.stderr) + return None + return path.read_text() + + +def extract_branch(prefix: str, filepath: str) -> str: + """Extract the branch/filename stem from a config file path. + + Example: + extract_branch("redhat-developer-rhdh-", ".../redhat-developer-rhdh-main.yaml") + => "main" + """ + name = Path(filepath).stem # removes .yaml + if name.startswith(prefix): + return name[len(prefix) :] + return name diff --git a/skills/prow/workflows/decommission-release.md b/skills/prow/workflows/decommission-release.md new file mode 100644 index 0000000..4c811ce --- /dev/null +++ b/skills/prow/workflows/decommission-release.md @@ -0,0 +1,31 @@ +# Decommission RHDH Release Branch Jobs + +Remove all CI configuration for a given RHDH release branch when it reaches end-of-life. Requires a local `openshift/release` checkout. + +## Steps + +1. **Get the release version**: + - If not provided, list existing configs: `ls ci-operator/config/redhat-developer/rhdh/redhat-developer-rhdh-release-*.yaml` + +2. **Verify files to be removed** (show the user and ask for confirmation): + - **CI config**: `ci-operator/config/redhat-developer/rhdh/redhat-developer-rhdh-release-{version}.yaml` + - **Generated jobs**: `ci-operator/jobs/redhat-developer/rhdh/redhat-developer-rhdh-release-{version}-*.yaml` + - **Branch protection**: `release-{version}:` block in `core-services/prow/02_config/redhat-developer/rhdh/_prowconfig.yaml` + +3. **Delete the CI config file** + +4. **Delete the generated job files** + +5. **Remove branch protection configuration**: + Edit `_prowconfig.yaml` to remove the entire `release-{version}:` block under `branch-protection.orgs.redhat-developer.repos.rhdh.branches`. Be careful to: + - Only remove the block for the specified version + - Preserve indentation and formatting of surrounding blocks + - Not leave blank lines where the block was removed + +6. **Confirm completion**: Summarize what was removed + +## Important Notes + +- **Do NOT run `make update`** -- only deleting files and removing branch protection +- This operation is destructive -- always confirm with the user before proceeding +- Always verify files exist before attempting deletion diff --git a/skills/prow/workflows/k8s-jobs.md b/skills/prow/workflows/k8s-jobs.md new file mode 100644 index 0000000..77a05b8 --- /dev/null +++ b/skills/prow/workflows/k8s-jobs.md @@ -0,0 +1,50 @@ +# K8s Platform Job Listing (AKS, EKS, GKE) + +List K8s platform test entries across RHDH release branches. + +## AKS + +```bash +uv run scripts/list_aks_jobs.py +uv run scripts/list_aks_jobs.py --branch main +``` + +## EKS + +```bash +uv run scripts/list_eks_jobs.py +uv run scripts/list_eks_jobs.py --branch main +``` + +## GKE + +```bash +uv run scripts/list_gke_jobs.py +uv run scripts/list_gke_jobs.py --branch main +``` + +## Updating K8s Versions (AKS/EKS, requires local checkout) + +The K8s version is set per branch as the `MAPT_KUBERNETES_VERSION` env var in each CI config file: + +```text +ci-operator/config/redhat-developer/rhdh/redhat-developer-rhdh-.yaml +``` + +Update all test entries in the file for the target platform. Example: + +```yaml + steps: + env: + MAPT_KUBERNETES_VERSION: "1.35" +``` + +`make update` is NOT required for version-only changes. + +## GKE Note + +GKE uses a pre-existing static cluster. Version upgrades are performed via the GCP Console, not CI config. + +## Related Workflows + +- Use the `lifecycle` skill to check K8s version support before updating diff --git a/skills/prow/workflows/ocp-coverage.md b/skills/prow/workflows/ocp-coverage.md new file mode 100644 index 0000000..9f54439 --- /dev/null +++ b/skills/prow/workflows/ocp-coverage.md @@ -0,0 +1,37 @@ +# OCP Coverage Analysis + +Cross-reference RHDH cluster pools, CI test configs, RHDH lifecycle, and OCP lifecycle data to identify coverage gaps and stale configurations. + +## Run + +```bash +uv run scripts/analyze_coverage.py +uv run scripts/analyze_coverage.py --repo-dir /path/to/openshift/release +``` + +## Two Dimensions of Support + +| Dimension | Source | Meaning | +|-----------|--------|---------| +| **OCP supported** | OCP lifecycle API | The OCP version itself is still receiving updates | +| **RHDH supported** | RHDH lifecycle API | RHDH officially supports running on this OCP version | + +Both must be satisfied for a cluster pool and test entry to exist. + +## Output + +1. **Current State** -- pools, test entries, lifecycle data +2. **OCP Version Matrix** -- combined view with OCP_SUPP, RHDH_SUPP, phase +3. **Analysis Results** -- categorized actions: + - **REMOVE** -- OCP version is end-of-life + - **REVIEW** -- needs judgment (compatibility mismatch) + - **ADD** -- missing pool or test entry +4. **Summary** -- counts per action category + +## Workflow After Analysis + +1. Handle REMOVE items first -- delete pools/tests for OCP-EOL versions +2. Review REVIEW items -- decide based on context +3. Handle ADD items -- use `workflows/ocp-pools.md` and `workflows/ocp-jobs.md` +4. Run `make update` after all changes +5. Commit both config and generated files together diff --git a/skills/prow/workflows/ocp-jobs.md b/skills/prow/workflows/ocp-jobs.md new file mode 100644 index 0000000..858b2a0 --- /dev/null +++ b/skills/prow/workflows/ocp-jobs.md @@ -0,0 +1,40 @@ +# OCP Test Entry Management + +Manage OCP-specific test entries in RHDH ci-operator configuration files. Covers tests that use OCP cluster claims (`cluster_claim.version`). + +## Listing + +```bash +uv run scripts/list_ocp_test_configs.py +uv run scripts/list_ocp_test_configs.py --branch main +``` + +## Generating a Test Entry + +```bash +uv run scripts/generate_test_entry.py --version 4.22 --branch main +uv run scripts/generate_test_entry.py --version 4.22 --branch main --reference 4.21 +``` + +The script outputs a ready-to-insert YAML block. + +## Adding a Test Entry (requires local checkout) + +1. Open the target config file (e.g., `ci-operator/config/redhat-developer/rhdh/redhat-developer-rhdh-main.yaml`) +2. Insert the new test entry in the `tests:` list, **before** `zz_generated_metadata:` +3. Place it adjacent to other `e2e-ocp-v*-helm-nightly` entries +4. Run `make update` + +### Fields to set for OCP version `X.Y` + +| Field | Value | +|-------|-------| +| `as` | `e2e-ocp-vX-Y-helm-nightly` | +| `cluster_claim.version` | `"X.Y"` | +| `steps.env.OC_CLIENT_VERSION` | `stable-X.Y` | + +## Removing a Test Entry (requires local checkout) + +1. Remove the entire test block where `as: e2e-ocp-vX-Y-helm-nightly` +2. Run `make update` +3. Check **all** product branch configs for entries that need removal diff --git a/skills/prow/workflows/ocp-pools.md b/skills/prow/workflows/ocp-pools.md new file mode 100644 index 0000000..f1c7c87 --- /dev/null +++ b/skills/prow/workflows/ocp-pools.md @@ -0,0 +1,37 @@ +# OCP Cluster Pool Management + +List and manage OCP Hive ClusterPool configurations for RHDH. Covers OCP cluster pools only -- K8s platforms (AKS, EKS, GKE) use different provisioning. + +## Listing + +```bash +uv run scripts/list_cluster_pools.py +``` + +## Generating a New Pool (requires local checkout) + +```bash +uv run scripts/generate_cluster_pool.py --version 4.22 +uv run scripts/generate_cluster_pool.py --version 4.22 --reference 4.21 +uv run scripts/generate_cluster_pool.py --version 4.22 --dry-run +``` + +The script: + +1. Looks up the `imageSetRef` by scanning ALL cluster pools across the repo +2. Copies an existing RHDH pool as a template +3. Updates version-specific fields, sets conservative sizing (`size: 1`, `maxSize: 2`) +4. Writes the file (or outputs to stdout with `--dry-run`) + +## Removing a Pool + +1. Delete the `*_clusterpool.yaml` file +2. Verify no CI jobs still reference this pool's version +3. Check for remaining OCP test entries (use `workflows/ocp-jobs.md`) + +## Key Details + +- **Region**: All RHDH pools use `us-east-2` +- **Architecture**: `amd64` +- **Namespace**: `rhdh-cluster-pool` +- **Pool files**: `clusters/hosted-mgmt/hive/pools/rhdh/`