Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 8 additions & 47 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions infrastructure/cilium/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
135 changes: 135 additions & 0 deletions scripts/validate.py
Original file line number Diff line number Diff line change
@@ -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())
Loading