From 74fa9873bf7abf8734707ea6a4f2e8449f95259c Mon Sep 17 00:00:00 2001 From: Giovanni Ferri Date: Wed, 22 Jul 2026 00:08:55 +0100 Subject: [PATCH] ci: data-driven render+schema validation via scripts/validate.py Replace the hand-rolled kubeconform find + per-chart helm-lint bash with a committed, locally-runnable script. It discovers Applications from the manifests themselves, renders each chart with its layered valueFiles (helm template), and schema-checks the rendered output plus every other manifest carrying a kind. Semantic filter (has-kind vs not) replaces fragile path exclusions. Also fix cilium values: tunnel: vxlan was removed in Cilium 1.15 -> routingMode + tunnelProtocol (surfaced by the new render check; main CI was already red). --- .github/workflows/ci.yml | 55 ++---------- infrastructure/cilium/values.yaml | 6 +- scripts/validate.py | 135 ++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 49 deletions(-) create mode 100755 scripts/validate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db0e438..5e387f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,28 +34,8 @@ jobs: - name: Detect secrets run: gitleaks detect --source . --redact - kubeconform: - name: Kubernetes manifest validation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install kubeconform - run: | - curl -sL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \ - | tar -xz -C /usr/local/bin kubeconform - - - name: Validate Kubernetes manifests - run: | - find bootstrap infrastructure clusters \( -name '*.yaml' -o -name '*.yml' \) \ - ! -name 'values.yaml' ! -name 'values.yml' 2>/dev/null \ - | xargs kubeconform \ - -strict \ - -ignore-missing-schemas \ - -summary - - helm-lint: - name: Helm lint + validate: + name: Manifest validation runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -65,34 +45,15 @@ jobs: with: version: "3.17.0" - - name: Add chart repositories - run: | - helm repo add cilium https://helm.cilium.io - helm repo add jetstack https://charts.jetstack.io - helm repo add argo https://argoproj.github.io/argo-helm - helm repo update - - - name: Lint Cilium chart + - name: Install kubeconform run: | - CHART_VERSION=$(python3 -c " - import yaml - doc = yaml.safe_load(open('infrastructure/cilium/application.yaml')) - srcs = doc['spec']['sources'] - print(next(s['targetRevision'] for s in srcs if 'chart' in s)) - ") - helm pull cilium/cilium --version "$CHART_VERSION" --untar --untardir /tmp/charts - helm lint /tmp/charts/cilium --values infrastructure/cilium/values.yaml + curl -sL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \ + | tar -xz -C /usr/local/bin kubeconform - - name: Lint cert-manager chart + - name: Render and schema-check manifests run: | - CHART_VERSION=$(python3 -c " - import yaml - doc = yaml.safe_load(open('infrastructure/cert-manager/application.yaml')) - srcs = doc['spec']['sources'] - print(next(s['targetRevision'] for s in srcs if 'chart' in s)) - ") - helm pull jetstack/cert-manager --version "$CHART_VERSION" --untar --untardir /tmp/charts - helm lint /tmp/charts/cert-manager --values infrastructure/cert-manager/values.yaml + pip install pyyaml --quiet + python3 scripts/validate.py pluto: name: API deprecation check diff --git a/infrastructure/cilium/values.yaml b/infrastructure/cilium/values.yaml index 5359cd1..cc4ec6e 100644 --- a/infrastructure/cilium/values.yaml +++ b/infrastructure/cilium/values.yaml @@ -17,8 +17,10 @@ cgroup: ipam: mode: kubernetes -# Tunnel mode (compatible with OCI VCN networking) -tunnel: vxlan +# Tunnel routing (compatible with OCI VCN networking). +# `tunnel: vxlan` was removed in Cilium 1.15 — split into routingMode + tunnelProtocol. +routingMode: tunnel +tunnelProtocol: vxlan # Security contexts required by Talos securityContext: diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100755 index 0000000..c627040 --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Validate the GitOps manifests the way Argo CD will actually apply them. + +Data-driven, no hardcoded chart list: + * Every Argo `Application` with a Helm chart source is rendered with its own + layered valueFiles (`helm template`) and the *output* is schema-checked + (`kubeconform`). This catches bad values and wrong CRDs, not just "the file + has a kind". + * Every other manifest that carries a `kind` is schema-checked directly. + * Files with no `kind` (Helm values, Talos inline-manifest patches, app.yaml + coord files) are not Kubernetes manifests and are skipped. + +Runs identically locally and in CI — `python3 scripts/validate.py`. Needs +`helm`; uses `kubeconform` when present (skips schema checks with a warning if +it is not, so `helm template` render errors still surface locally). +""" + +import glob +import os +import shutil +import subprocess +import sys + +import yaml + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SEARCH_DIRS = ["bootstrap", "infrastructure", "clusters", "apps", "types"] +TOP_LEVEL = ["appset.yaml"] +KUBECONFORM = shutil.which("kubeconform") +KC_ARGS = ["-strict", "-ignore-missing-schemas", "-summary"] + + +def ci_error(msg: str) -> None: + print(f"::error::{msg}" if os.environ.get("CI") else f"ERROR: {msg}") + + +def load_docs(path: str): + """Return the list of mapping documents, or None on a YAML parse error.""" + with open(path) as f: + try: + return [d for d in yaml.safe_load_all(f) if isinstance(d, dict)] + except yaml.YAMLError as exc: + ci_error(f"{path}: YAML parse error: {exc}") + return None + + +def helm_chart_source(app: dict): + """(repoURL, chart, version, [valueFiles]) for the chart source, else None.""" + spec = app.get("spec", {}) + sources = spec.get("sources") or ([spec["source"]] if "source" in spec else []) + for src in sources: + if "chart" in src: + value_files = [ + vf.replace("$values/", "", 1) + for vf in src.get("helm", {}).get("valueFiles", []) + ] + return src["repoURL"], src["chart"], str(src.get("targetRevision", "")), value_files + return None + + +def render_and_check(name: str, repo: str, chart: str, version: str, value_files) -> bool: + """helm template the chart with its values, pipe the output to kubeconform.""" + if repo.startswith(("http://", "https://")): + chart_ref, repo_args = chart, ["--repo", repo] + else: # OCI registry, e.g. ghcr.io/actions/... + chart_ref, repo_args = f"oci://{repo.rstrip('/')}/{chart}", [] + + cmd = ["helm", "template", name, chart_ref, *repo_args] + if version: + cmd += ["--version", version] + for vf in value_files: + if not os.path.exists(vf): + ci_error(f"{name}: valueFile not found: {vf}") + return False + cmd += ["-f", vf] + + tmpl = subprocess.run(cmd, capture_output=True, text=True) + if tmpl.returncode != 0: + ci_error(f"helm template failed for {name}:\n{tmpl.stderr.strip()}") + return False + + if not KUBECONFORM: + return True + check = subprocess.run( + [KUBECONFORM, *KC_ARGS], input=tmpl.stdout, capture_output=True, text=True + ) + sys.stdout.write(check.stdout) + if check.returncode != 0: + ci_error(f"kubeconform failed on rendered {name}:\n{check.stderr.strip()}") + return False + return True + + +def main() -> int: + os.chdir(ROOT) + files = [] + for d in SEARCH_DIRS: + for ext in ("yaml", "yml"): + files += glob.glob(f"{d}/**/*.{ext}", recursive=True) + files += [f for f in TOP_LEVEL if os.path.exists(f)] + + ok = True + schema_targets = [] # manifests with a kind, schema-checked in one batch + + for path in sorted(set(files)): + docs = load_docs(path) + if docs is None: + ok = False + continue + if not any("kind" in d for d in docs): + continue # values / Talos patch / coord file — not a k8s manifest + + for doc in docs: + if doc.get("kind") == "Application": + source = helm_chart_source(doc) + if source: + name = doc.get("metadata", {}).get("name", "app") + print(f"── render Application {name} ({path})") + if not render_and_check(name, *source): + ok = False + schema_targets.append(path) + + if schema_targets and KUBECONFORM: + print("── schema-check manifests") + if subprocess.run([KUBECONFORM, *KC_ARGS, *schema_targets]).returncode != 0: + ok = False + elif not KUBECONFORM: + print("WARNING: kubeconform not found — ran helm template only, skipped schema checks") + + print("OK" if ok else "FAILED") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())