Skip to content

Commit 2e86c2b

Browse files
ci: add kustomize build verification workflow (#37)
* 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 * 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> * 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 * 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 * ci: upload kustomize build artifacts - Add upload-artifact step to preserve built kustomize YAML files - Artifact naming includes PR number (or 'main' for push) and run ID - Set retention to 30 days for PR workflow Made-with: opencode Model: big-pickle (opencode/big-pickle) --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent d07200c commit 2e86c2b

3 files changed

Lines changed: 317 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#!/usr/bin/env python3
2+
"""Verify that all rhoso-gitops components build successfully with kustomize.
3+
4+
Discovers components dynamically under components/rhoso/ and example/,
5+
runs kustomize build for each, and reports a summary table. Fails only at
6+
the end if any component failed.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import subprocess
12+
import sys
13+
import yaml
14+
from dataclasses import dataclass
15+
from pathlib import Path
16+
17+
18+
KUSTOMIZATION_FILES = ("kustomization.yaml", "kustomization.yml", "Kustomization")
19+
RHOSO_COMPONENTS_ROOT = Path("components/rhoso")
20+
EXAMPLES_ROOT = Path("example")
21+
BUILD_TEST_DIR = Path(".build-test")
22+
23+
24+
@dataclass
25+
class BuildTestCase:
26+
"""A single component or overlay to test."""
27+
28+
id: str
29+
component_paths: list[Path]
30+
build_dir_name: str
31+
# If set, build directly from this directory (e.g. examples). Otherwise generate kustomization.
32+
source_directory: Path | None = None
33+
34+
35+
@dataclass
36+
class BuildResult:
37+
"""Result of running kustomize build on a test case."""
38+
39+
test_case: BuildTestCase
40+
success: bool
41+
error_message: str = ""
42+
43+
44+
def discover_rhoso_components(repo_root: Path) -> list[BuildTestCase]:
45+
"""Discover all buildable components under components/rhoso/."""
46+
cases: list[BuildTestCase] = []
47+
rhoso_root = repo_root / RHOSO_COMPONENTS_ROOT
48+
49+
if not rhoso_root.exists():
50+
return cases
51+
52+
for kustomization_path in _find_kustomization_files(rhoso_root):
53+
rel_path = kustomization_path.relative_to(rhoso_root).parent
54+
path_parts = rel_path.parts
55+
56+
# Service pattern: .../services/<name>/ requires parent as base
57+
if "services" in path_parts:
58+
services_idx = path_parts.index("services")
59+
parent_parts = path_parts[:services_idx]
60+
parent_path = rhoso_root.joinpath(*parent_parts)
61+
62+
if parent_path.exists() and (parent_path / "kustomization.yaml").exists():
63+
components = [
64+
repo_root / RHOSO_COMPONENTS_ROOT / Path(*parent_parts),
65+
repo_root / RHOSO_COMPONENTS_ROOT / rel_path,
66+
]
67+
else:
68+
components = [repo_root / RHOSO_COMPONENTS_ROOT / rel_path]
69+
else:
70+
components = [repo_root / RHOSO_COMPONENTS_ROOT / rel_path]
71+
72+
id_str = str(rel_path).replace("\\", "/")
73+
slug = id_str.replace("/", "-")
74+
cases.append(
75+
BuildTestCase(
76+
id=f"rhoso/{id_str}",
77+
component_paths=components,
78+
build_dir_name=f"rhoso-{slug}",
79+
)
80+
)
81+
82+
return cases
83+
84+
85+
def discover_examples(repo_root: Path) -> list[BuildTestCase]:
86+
"""Discover all example overlays. No filtering - test every example as committed."""
87+
cases: list[BuildTestCase] = []
88+
examples_root = repo_root / EXAMPLES_ROOT
89+
90+
if not examples_root.exists():
91+
return cases
92+
93+
for example_dir in sorted(examples_root.iterdir()):
94+
if not example_dir.is_dir():
95+
continue
96+
97+
if _find_kustomization_in_dir(example_dir) is None:
98+
continue
99+
100+
rel_example = example_dir.relative_to(repo_root)
101+
id_str = str(rel_example).replace("\\", "/")
102+
slug = id_str.replace("/", "-")
103+
cases.append(
104+
BuildTestCase(
105+
id=id_str,
106+
component_paths=[], # Unused: we build directly from source_directory
107+
build_dir_name=f"example-{slug}",
108+
source_directory=example_dir,
109+
)
110+
)
111+
112+
return cases
113+
114+
115+
def _find_kustomization_files(root: Path) -> list[Path]:
116+
"""Find all kustomization files under root."""
117+
results: list[Path] = []
118+
for f in root.rglob("*"):
119+
if f.is_file() and f.name in KUSTOMIZATION_FILES:
120+
results.append(f)
121+
return sorted(results)
122+
123+
124+
def _find_kustomization_in_dir(directory: Path) -> Path | None:
125+
"""Return the kustomization file in dir, or None."""
126+
for name in KUSTOMIZATION_FILES:
127+
path = directory / name
128+
if path.exists():
129+
return path
130+
return None
131+
132+
133+
def generate_kustomization(
134+
build_dir: Path, component_paths: list[Path], repo_root: Path
135+
) -> None:
136+
"""Write a minimal kustomization.yaml referencing the given components."""
137+
rel_paths: list[str] = []
138+
for comp in component_paths:
139+
resolved = (repo_root / comp).resolve() if not comp.is_absolute() else comp
140+
rel = Path("..") / ".." / resolved.relative_to(repo_root)
141+
rel_paths.append(str(rel).replace("\\", "/"))
142+
143+
kustomization = {
144+
"apiVersion": "kustomize.config.k8s.io/v1beta1",
145+
"kind": "Kustomization",
146+
"components": rel_paths,
147+
}
148+
149+
out_path = build_dir / "kustomization.yaml"
150+
out_path.write_text(
151+
yaml.dump(kustomization, default_flow_style=False, sort_keys=False)
152+
)
153+
154+
155+
def run_kustomize_build(build_dir: Path) -> tuple[bool, str]:
156+
"""Run kustomize build in build_dir. Return (success, error_message)."""
157+
try:
158+
result = subprocess.run(
159+
["kustomize", "build", "."],
160+
cwd=build_dir,
161+
capture_output=True,
162+
text=True,
163+
timeout=60,
164+
)
165+
if result.returncode == 0:
166+
return True, ""
167+
return False, result.stderr or result.stdout or f"Exit code {result.returncode}"
168+
except subprocess.TimeoutExpired:
169+
return False, "Command timed out after 60s"
170+
except FileNotFoundError:
171+
return False, "kustomize not found in PATH"
172+
except Exception as e:
173+
return False, str(e)
174+
175+
176+
def build_and_collect(
177+
test_case: BuildTestCase, repo_root: Path, build_base: Path
178+
) -> BuildResult:
179+
"""Generate kustomization (or use source dir), run build, return result."""
180+
if test_case.source_directory is not None:
181+
# Build directly from the example directory (tests refs, patches, etc. as committed)
182+
build_dir = test_case.source_directory
183+
else:
184+
build_dir = build_base / test_case.build_dir_name
185+
build_dir.mkdir(parents=True, exist_ok=True)
186+
generate_kustomization(build_dir, test_case.component_paths, repo_root)
187+
188+
success, error = run_kustomize_build(build_dir)
189+
190+
return BuildResult(
191+
test_case=test_case,
192+
success=success,
193+
error_message=error[:300] if error else "",
194+
)
195+
196+
197+
def format_results_table(results: list[BuildResult]) -> str:
198+
"""Format results as a readable table."""
199+
lines: list[str] = []
200+
col_width = 45
201+
status_width = 10
202+
203+
header = f"| {'Component':<{col_width}} | {'Status':<{status_width}} |"
204+
separator = f"|{'-' * (col_width + 2)}|{'-' * (status_width + 2)}|"
205+
lines.append(header)
206+
lines.append(separator)
207+
208+
for r in results:
209+
status = "OK" if r.success else "FAILED"
210+
id_display = (
211+
r.test_case.id[:col_width]
212+
if len(r.test_case.id) <= col_width
213+
else r.test_case.id[: col_width - 3] + "..."
214+
)
215+
lines.append(f"| {id_display:<{col_width}} | {status:<{status_width}} |")
216+
217+
return "\n".join(lines)
218+
219+
220+
def format_failures_summary(results: list[BuildResult]) -> str:
221+
"""Format detailed failure messages."""
222+
failed = [r for r in results if not r.success]
223+
if not failed:
224+
return ""
225+
226+
lines: list[str] = ["", "Failed components:", ""]
227+
for r in failed:
228+
lines.append(f" {r.test_case.id}")
229+
if r.error_message:
230+
for err_line in r.error_message.strip().split("\n")[:5]:
231+
lines.append(f" {err_line}")
232+
lines.append("")
233+
return "\n".join(lines)
234+
235+
236+
def main() -> int:
237+
"""Discover components, run builds, report results. Returns 1 if any failed."""
238+
repo_root = Path(__file__).resolve().parent.parent.parent
239+
240+
test_cases: list[BuildTestCase] = []
241+
test_cases.extend(discover_rhoso_components(repo_root))
242+
test_cases.extend(discover_examples(repo_root))
243+
244+
if not test_cases:
245+
print("No components to test.")
246+
return 0
247+
248+
build_base = repo_root / BUILD_TEST_DIR
249+
build_base.mkdir(exist_ok=True)
250+
251+
results: list[BuildResult] = []
252+
for tc in test_cases:
253+
result = build_and_collect(tc, repo_root, build_base)
254+
results.append(result)
255+
status = "OK" if result.success else "FAIL"
256+
print(f" [{status}] {tc.id}", flush=True)
257+
258+
print()
259+
print(format_results_table(results))
260+
print(format_failures_summary(results))
261+
262+
failed_count = sum(1 for r in results if not r.success)
263+
if failed_count > 0:
264+
print(f"\n{failed_count} component(s) failed.")
265+
return 1
266+
return 0
267+
268+
269+
if __name__ == "__main__":
270+
sys.exit(main())
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
name: kustomize-build
3+
permissions:
4+
contents: read
5+
on: # yamllint disable-line rule:truthy
6+
pull_request:
7+
branches:
8+
- main
9+
paths:
10+
- "components/rhoso/**"
11+
- "example/**"
12+
- ".github/workflows/kustomize-build.yml"
13+
- ".github/scripts/verify-kustomize-builds.py"
14+
push:
15+
branches:
16+
- main
17+
paths:
18+
- "components/rhoso/**"
19+
- "example/**"
20+
- ".github/workflows/kustomize-build.yml"
21+
- ".github/scripts/verify-kustomize-builds.py"
22+
jobs:
23+
kustomize-build:
24+
runs-on: ubuntu-latest
25+
env:
26+
KUSTOMIZE_VERSION: "5.4.1"
27+
steps:
28+
- name: Checkout
29+
uses: actions/checkout@v4
30+
31+
- name: Setup Python
32+
uses: actions/setup-python@v5
33+
with:
34+
python-version: "3.13"
35+
36+
- name: Install Kustomize
37+
run: |
38+
curl -sLf "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" | tar -xzf -
39+
sudo mv kustomize /usr/local/bin/
40+
41+
- name: Install PyYAML
42+
run: pip install pyyaml
43+
44+
- name: Verify Kustomize builds
45+
run: python .github/scripts/verify-kustomize-builds.py

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
environments/*
2+
.build-test/
3+
.venv-lint/

0 commit comments

Comments
 (0)