From 48655e92fe97db482df5b092d69bd3462c7eca10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Jeanneret?= Date: Wed, 11 Mar 2026 10:08:07 +0100 Subject: [PATCH 1/5] ci: add kustomize build verification workflow Add a GitHub workflow that ensures rhoso-gitops components and example overlays build successfully with kustomize. The verify-kustomize-builds.py script: - Discovers components dynamically under components/rhoso/ (no static list) - Discovers example overlays under example/ by parsing their kustomization - For service components (e.g. controlplane/services/watcher), references the parent component together with the service - For examples, runs kustomize build directly on the example directory (catches invalid refs, broken patches) - Runs all tests before reporting; fails only at the end with a readable summary table - Excludes components/argocd/ from testing Code generated through an interactive collaboration between a human and AI. AI-Assisted-By: Cursor IDE, Composer (Agent mode) Made-with: Cursor --- .github/scripts/verify-kustomize-builds.py | 318 +++++++++++++++++++++ .github/workflows/kustomize-build.yml | 39 +++ .gitignore | 1 + 3 files changed, 358 insertions(+) create mode 100755 .github/scripts/verify-kustomize-builds.py create mode 100644 .github/workflows/kustomize-build.yml diff --git a/.github/scripts/verify-kustomize-builds.py b/.github/scripts/verify-kustomize-builds.py new file mode 100755 index 0000000..c4a0ef9 --- /dev/null +++ b/.github/scripts/verify-kustomize-builds.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Verify that all rhoso-gitops components build successfully with kustomize. + +Discovers components dynamically under components/rhoso/ and example/, +runs kustomize build for each, and reports a summary table. Fails only at +the end if any component failed. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +KUSTOMIZATION_FILES = ("kustomization.yaml", "kustomization.yml", "Kustomization") +RHOSO_COMPONENTS_ROOT = Path("components/rhoso") +EXAMPLES_ROOT = Path("example") +BUILD_TEST_DIR = Path(".build-test") +RHOSO_GITOPS_URL_PATTERN = re.compile( + r"github\.com/openstack-gitops/rhoso-gitops/components/([^?]+)" +) + + +@dataclass +class BuildTestCase: + """A single component or overlay to test.""" + + id: str + component_paths: list[Path] + build_dir_name: str + # If set, build directly from this directory (e.g. examples). Otherwise generate kustomization. + source_directory: Path | None = None + + +@dataclass +class BuildResult: + """Result of running kustomize build on a test case.""" + + test_case: BuildTestCase + success: bool + error_message: str = "" + + +def discover_rhoso_components(repo_root: Path) -> list[BuildTestCase]: + """Discover all buildable components under components/rhoso/.""" + cases: list[BuildTestCase] = [] + rhoso_root = repo_root / RHOSO_COMPONENTS_ROOT + + if not rhoso_root.exists(): + return cases + + for kustomization_path in _find_kustomization_files(rhoso_root): + rel_path = kustomization_path.relative_to(rhoso_root).parent + path_parts = rel_path.parts + + # Service pattern: .../services// requires parent as base + if "services" in path_parts: + services_idx = path_parts.index("services") + parent_parts = path_parts[:services_idx] + parent_path = rhoso_root.joinpath(*parent_parts) + + if parent_path.exists() and (parent_path / "kustomization.yaml").exists(): + components = [ + repo_root / RHOSO_COMPONENTS_ROOT / Path(*parent_parts), + repo_root / RHOSO_COMPONENTS_ROOT / rel_path, + ] + else: + components = [repo_root / RHOSO_COMPONENTS_ROOT / rel_path] + else: + components = [repo_root / RHOSO_COMPONENTS_ROOT / rel_path] + + id_str = str(rel_path).replace("\\", "/") + slug = id_str.replace("/", "-") + cases.append( + BuildTestCase( + id=f"rhoso/{id_str}", + component_paths=components, + build_dir_name=f"rhoso-{slug}", + ) + ) + + return cases + + +def discover_examples(repo_root: Path) -> list[BuildTestCase]: + """Discover example overlays and extract their rhoso component refs.""" + cases: list[BuildTestCase] = [] + examples_root = repo_root / EXAMPLES_ROOT + + if not examples_root.exists(): + return cases + + for example_dir in sorted(examples_root.iterdir()): + if not example_dir.is_dir(): + continue + + kustomization_path = _find_kustomization_in_dir(example_dir) + if kustomization_path is None: + continue + + component_paths = _parse_example_components(kustomization_path, repo_root) + if not component_paths: + continue + + rel_example = example_dir.relative_to(repo_root) + id_str = str(rel_example).replace("\\", "/") + slug = id_str.replace("/", "-") + cases.append( + BuildTestCase( + id=id_str, + component_paths=component_paths, + build_dir_name=f"example-{slug}", + source_directory=example_dir, + ) + ) + + return cases + + +def _find_kustomization_files(root: Path) -> list[Path]: + """Find all kustomization files under root.""" + results: list[Path] = [] + for f in root.rglob("*"): + if f.is_file() and f.name in KUSTOMIZATION_FILES: + results.append(f) + return sorted(results) + + +def _find_kustomization_in_dir(directory: Path) -> Path | None: + """Return the kustomization file in dir, or None.""" + for name in KUSTOMIZATION_FILES: + path = directory / name + if path.exists(): + return path + return None + + +def _parse_example_components(kustomization_path: Path, repo_root: Path) -> list[Path]: + """Parse example kustomization and extract local rhoso component paths.""" + import yaml + + try: + content = kustomization_path.read_text() + data = yaml.safe_load(content) + except (OSError, yaml.YAMLError): + return [] + + components = data.get("components") or [] + local_paths: list[Path] = [] + + for item in components: + if not isinstance(item, str): + continue + + # Extract path from rhoso-gitops URLs, filter argocd and external + match = RHOSO_GITOPS_URL_PATTERN.search(item) + if match: + component_rel = match.group(1).strip("/") + if "argocd" in component_rel: + continue + local_paths.append(repo_root / "components" / component_rel) + elif item.startswith("#"): + # Commented line, skip + continue + elif not item.startswith("http"): + # Local path in example + example_dir = kustomization_path.parent + resolved = (example_dir / item).resolve() + try: + rel = resolved.relative_to(repo_root) + except ValueError: + continue + rel_str = str(rel).replace("\\", "/") + if "argocd" in rel_str: + continue + if "components/rhoso" in rel_str: + local_paths.append(repo_root / rel) + + return local_paths + + +def generate_kustomization( + build_dir: Path, component_paths: list[Path], repo_root: Path +) -> None: + """Write a minimal kustomization.yaml referencing the given components.""" + rel_paths: list[str] = [] + for comp in component_paths: + resolved = (repo_root / comp).resolve() if not comp.is_absolute() else comp + rel = Path("..") / ".." / resolved.relative_to(repo_root) + rel_paths.append(str(rel).replace("\\", "/")) + + kustomization = { + "apiVersion": "kustomize.config.k8s.io/v1beta1", + "kind": "Kustomization", + "components": rel_paths, + } + + import yaml + + out_path = build_dir / "kustomization.yaml" + out_path.write_text(yaml.dump(kustomization, default_flow_style=False, sort_keys=False)) + + +def run_kustomize_build(build_dir: Path) -> tuple[bool, str]: + """Run kustomize build in build_dir. Return (success, error_message).""" + try: + result = subprocess.run( + ["kustomize", "build", "."], + cwd=build_dir, + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode == 0: + return True, "" + return False, result.stderr or result.stdout or f"Exit code {result.returncode}" + except subprocess.TimeoutExpired: + return False, "Command timed out after 60s" + except FileNotFoundError: + return False, "kustomize not found in PATH" + except Exception as e: + return False, str(e) + + +def build_and_collect( + test_case: BuildTestCase, repo_root: Path, build_base: Path +) -> BuildResult: + """Generate kustomization (or use source dir), run build, return result.""" + if test_case.source_directory is not None: + # Build directly from the example directory (tests refs, patches, etc. as committed) + build_dir = test_case.source_directory + else: + build_dir = build_base / test_case.build_dir_name + build_dir.mkdir(parents=True, exist_ok=True) + generate_kustomization(build_dir, test_case.component_paths, repo_root) + + success, error = run_kustomize_build(build_dir) + + return BuildResult( + test_case=test_case, + success=success, + error_message=error[:300] if error else "", + ) + + +def format_results_table(results: list[BuildResult]) -> str: + """Format results as a readable table.""" + lines: list[str] = [] + col_width = 45 + status_width = 10 + + header = f"| {'Component':<{col_width}} | {'Status':<{status_width}} |" + separator = f"|{'-' * (col_width + 2)}|{'-' * (status_width + 2)}|" + lines.append(header) + lines.append(separator) + + for r in results: + status = "OK" if r.success else "FAILED" + id_display = r.test_case.id[:col_width] if len(r.test_case.id) <= col_width else r.test_case.id[: col_width - 3] + "..." + lines.append(f"| {id_display:<{col_width}} | {status:<{status_width}} |") + + return "\n".join(lines) + + +def format_failures_summary(results: list[BuildResult]) -> str: + """Format detailed failure messages.""" + failed = [r for r in results if not r.success] + if not failed: + return "" + + lines: list[str] = ["", "Failed components:", ""] + for r in failed: + lines.append(f" {r.test_case.id}") + if r.error_message: + for err_line in r.error_message.strip().split("\n")[:5]: + lines.append(f" {err_line}") + lines.append("") + return "\n".join(lines) + + +def main() -> int: + """Discover components, run builds, report results. Returns 1 if any failed.""" + repo_root = Path(__file__).resolve().parent.parent.parent + + test_cases: list[BuildTestCase] = [] + test_cases.extend(discover_rhoso_components(repo_root)) + test_cases.extend(discover_examples(repo_root)) + + if not test_cases: + print("No components to test.") + return 0 + + build_base = repo_root / BUILD_TEST_DIR + build_base.mkdir(exist_ok=True) + + results: list[BuildResult] = [] + for tc in test_cases: + result = build_and_collect(tc, repo_root, build_base) + results.append(result) + status = "OK" if result.success else "FAIL" + print(f" [{status}] {tc.id}", flush=True) + + print() + print(format_results_table(results)) + print(format_failures_summary(results)) + + failed_count = sum(1 for r in results if not r.success) + if failed_count > 0: + print(f"\n{failed_count} component(s) failed.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/kustomize-build.yml b/.github/workflows/kustomize-build.yml new file mode 100644 index 0000000..ad33c70 --- /dev/null +++ b/.github/workflows/kustomize-build.yml @@ -0,0 +1,39 @@ +--- +name: kustomize-build +on: # yamllint disable-line rule:truthy + pull_request: + branches: + - main + paths: + - "components/rhoso/**" + - "example/**" + - ".github/workflows/kustomize-build.yml" + - ".github/scripts/verify-kustomize-builds.py" + push: + branches: + - main + paths: + - "components/rhoso/**" + - "example/**" + - ".github/workflows/kustomize-build.yml" + - ".github/scripts/verify-kustomize-builds.py" +jobs: + kustomize-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Kustomize + uses: syntaqx/setup-kustomize@v1 + + - name: Install PyYAML + run: pip install pyyaml + + - name: Verify Kustomize builds + run: python .github/scripts/verify-kustomize-builds.py diff --git a/.gitignore b/.gitignore index daf3313..5e82758 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ environments/* +.build-test/ From 5c511e1c7928861e6a1a0d6d91f5d1574d612fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Jeanneret?= <39397510+cjeanner@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:12:01 +0100 Subject: [PATCH 2/5] Potential fix for code scanning alert no. 2: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/kustomize-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/kustomize-build.yml b/.github/workflows/kustomize-build.yml index ad33c70..3b7c932 100644 --- a/.github/workflows/kustomize-build.yml +++ b/.github/workflows/kustomize-build.yml @@ -1,5 +1,7 @@ --- name: kustomize-build +permissions: + contents: read on: # yamllint disable-line rule:truthy pull_request: branches: From c2545c67b002d0e6492ad5ae47846e30401ac62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Jeanneret?= Date: Wed, 11 Mar 2026 11:48:58 +0100 Subject: [PATCH 3/5] ci: replace third-party setup-kustomize action with direct install Remove syntaqx/setup-kustomize@v1 and install kustomize directly from the official kubernetes-sigs/kustomize releases. This addresses potential security concerns flagged by GitHub CodeQL regarding third-party actions (e.g. dependency confusion, supply chain risk). The workflow now downloads kustomize v5.4.1 from: https://github.com/kubernetes-sigs/kustomize/releases Made-with: Cursor --- .github/workflows/kustomize-build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/kustomize-build.yml b/.github/workflows/kustomize-build.yml index 3b7c932..ffb9d3c 100644 --- a/.github/workflows/kustomize-build.yml +++ b/.github/workflows/kustomize-build.yml @@ -32,7 +32,10 @@ jobs: python-version: "3.13" - name: Install Kustomize - uses: syntaqx/setup-kustomize@v1 + run: | + KUSTOMIZE_VERSION="5.4.1" + curl -sLf "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" | tar -xzf - + sudo mv kustomize /usr/local/bin/ - name: Install PyYAML run: pip install pyyaml From 177697e32bb3c58fa86be67377cdffde80a4c4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Jeanneret?= Date: Wed, 11 Mar 2026 13:42:03 +0100 Subject: [PATCH 4/5] ci: test all examples without filtering, apply ruff linter - Remove example filtering: test every example as committed, including those consuming remote/external content (e.g. example/dependencies) - Delete _parse_example_components and RHOSO_GITOPS_URL_PATTERN - Apply ruff format for style consistency - Add .venv-lint/ to .gitignore Made-with: Cursor --- .github/scripts/verify-kustomize-builds.py | 69 ++++------------------ .gitignore | 1 + 2 files changed, 12 insertions(+), 58 deletions(-) diff --git a/.github/scripts/verify-kustomize-builds.py b/.github/scripts/verify-kustomize-builds.py index c4a0ef9..48918d7 100755 --- a/.github/scripts/verify-kustomize-builds.py +++ b/.github/scripts/verify-kustomize-builds.py @@ -8,7 +8,6 @@ from __future__ import annotations -import re import subprocess import sys from dataclasses import dataclass @@ -19,9 +18,6 @@ RHOSO_COMPONENTS_ROOT = Path("components/rhoso") EXAMPLES_ROOT = Path("example") BUILD_TEST_DIR = Path(".build-test") -RHOSO_GITOPS_URL_PATTERN = re.compile( - r"github\.com/openstack-gitops/rhoso-gitops/components/([^?]+)" -) @dataclass @@ -86,7 +82,7 @@ def discover_rhoso_components(repo_root: Path) -> list[BuildTestCase]: def discover_examples(repo_root: Path) -> list[BuildTestCase]: - """Discover example overlays and extract their rhoso component refs.""" + """Discover all example overlays. No filtering - test every example as committed.""" cases: list[BuildTestCase] = [] examples_root = repo_root / EXAMPLES_ROOT @@ -97,12 +93,7 @@ def discover_examples(repo_root: Path) -> list[BuildTestCase]: if not example_dir.is_dir(): continue - kustomization_path = _find_kustomization_in_dir(example_dir) - if kustomization_path is None: - continue - - component_paths = _parse_example_components(kustomization_path, repo_root) - if not component_paths: + if _find_kustomization_in_dir(example_dir) is None: continue rel_example = example_dir.relative_to(repo_root) @@ -111,7 +102,7 @@ def discover_examples(repo_root: Path) -> list[BuildTestCase]: cases.append( BuildTestCase( id=id_str, - component_paths=component_paths, + component_paths=[], # Unused: we build directly from source_directory build_dir_name=f"example-{slug}", source_directory=example_dir, ) @@ -138,50 +129,6 @@ def _find_kustomization_in_dir(directory: Path) -> Path | None: return None -def _parse_example_components(kustomization_path: Path, repo_root: Path) -> list[Path]: - """Parse example kustomization and extract local rhoso component paths.""" - import yaml - - try: - content = kustomization_path.read_text() - data = yaml.safe_load(content) - except (OSError, yaml.YAMLError): - return [] - - components = data.get("components") or [] - local_paths: list[Path] = [] - - for item in components: - if not isinstance(item, str): - continue - - # Extract path from rhoso-gitops URLs, filter argocd and external - match = RHOSO_GITOPS_URL_PATTERN.search(item) - if match: - component_rel = match.group(1).strip("/") - if "argocd" in component_rel: - continue - local_paths.append(repo_root / "components" / component_rel) - elif item.startswith("#"): - # Commented line, skip - continue - elif not item.startswith("http"): - # Local path in example - example_dir = kustomization_path.parent - resolved = (example_dir / item).resolve() - try: - rel = resolved.relative_to(repo_root) - except ValueError: - continue - rel_str = str(rel).replace("\\", "/") - if "argocd" in rel_str: - continue - if "components/rhoso" in rel_str: - local_paths.append(repo_root / rel) - - return local_paths - - def generate_kustomization( build_dir: Path, component_paths: list[Path], repo_root: Path ) -> None: @@ -201,7 +148,9 @@ def generate_kustomization( import yaml out_path = build_dir / "kustomization.yaml" - out_path.write_text(yaml.dump(kustomization, default_flow_style=False, sort_keys=False)) + out_path.write_text( + yaml.dump(kustomization, default_flow_style=False, sort_keys=False) + ) def run_kustomize_build(build_dir: Path) -> tuple[bool, str]: @@ -259,7 +208,11 @@ def format_results_table(results: list[BuildResult]) -> str: for r in results: status = "OK" if r.success else "FAILED" - id_display = r.test_case.id[:col_width] if len(r.test_case.id) <= col_width else r.test_case.id[: col_width - 3] + "..." + id_display = ( + r.test_case.id[:col_width] + if len(r.test_case.id) <= col_width + else r.test_case.id[: col_width - 3] + "..." + ) lines.append(f"| {id_display:<{col_width}} | {status:<{status_width}} |") return "\n".join(lines) diff --git a/.gitignore b/.gitignore index 5e82758..1d04a85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ environments/* .build-test/ +.venv-lint/ From 333342dd2312ed30605011a7f02fac837d957af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Jeanneret?= Date: Fri, 13 Mar 2026 08:31:02 +0100 Subject: [PATCH 5/5] ci: address review comments from aharivel - Use env block for KUSTOMIZE_VERSION in workflow - Move yaml import to top-level in verify-kustomize-builds.py AI-Assisted-By: OpenCode (big-pickle model) Made-with: OpenCode --- .github/scripts/verify-kustomize-builds.py | 3 +-- .github/workflows/kustomize-build.yml | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/verify-kustomize-builds.py b/.github/scripts/verify-kustomize-builds.py index 48918d7..d44335b 100755 --- a/.github/scripts/verify-kustomize-builds.py +++ b/.github/scripts/verify-kustomize-builds.py @@ -10,6 +10,7 @@ import subprocess import sys +import yaml from dataclasses import dataclass from pathlib import Path @@ -145,8 +146,6 @@ def generate_kustomization( "components": rel_paths, } - import yaml - out_path = build_dir / "kustomization.yaml" out_path.write_text( yaml.dump(kustomization, default_flow_style=False, sort_keys=False) diff --git a/.github/workflows/kustomize-build.yml b/.github/workflows/kustomize-build.yml index ffb9d3c..70e0e61 100644 --- a/.github/workflows/kustomize-build.yml +++ b/.github/workflows/kustomize-build.yml @@ -22,6 +22,8 @@ on: # yamllint disable-line rule:truthy jobs: kustomize-build: runs-on: ubuntu-latest + env: + KUSTOMIZE_VERSION: "5.4.1" steps: - name: Checkout uses: actions/checkout@v4 @@ -33,7 +35,6 @@ jobs: - name: Install Kustomize run: | - KUSTOMIZE_VERSION="5.4.1" curl -sLf "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" | tar -xzf - sudo mv kustomize /usr/local/bin/