Skip to content

Commit 48655e9

Browse files
committed
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
1 parent d07200c commit 48655e9

3 files changed

Lines changed: 358 additions & 0 deletions

File tree

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

.gitignore

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

0 commit comments

Comments
 (0)