From 6b2c04ed5c6c0b9c237bc6746c8dc2c79daed602 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 24 Aug 2026 22:18:58 -0500 Subject: [PATCH 1/3] Add ServiceDefinition renderer (T-0.3, R-GITOPS-02) catalog/render.py compiles a ServiceDefinition into the manifest set defined by schemas/renderer-contract.md: Namespace + default-deny baseline (R-NET-01), Deployment/Service or KubeVirt VM + CDI DataVolume (R-PLAT-02/03), PVCs (R-STATE), per-dependency CiliumNetworkPolicy allows (R-NET-02), egress-class steering label (R-NET-05), HTTPRoutes on the shared Gateway (R-TLS/R-DNS), ServiceMonitor + SLI alerts (R-OBS-01), Flux image-automation (R-UPD), auth-mode/backup hints (R-IDP). Deterministic (golden-diffable) with a built-in --selftest; single dependency (PyYAML) so the conformance harness stays stdlib-only. Documents the mechanism choice in catalog/README.md and the script header. Co-Authored-By: Claude Opus 4.8 --- catalog/README.md | 26 ++ catalog/render.py | 774 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 800 insertions(+) create mode 100644 catalog/render.py diff --git a/catalog/README.md b/catalog/README.md index 912b9af..e28b689 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -35,3 +35,29 @@ check-jsonschema --schemafile schemas/servicedefinition.schema.json catalog/*.ya ``` See [`../schemas/README.md`](../schemas/README.md) for install/pinning and a pure-Python fallback. This is the L0/L1 schema gate (spec `docs/14`); CI (`.gitea/`) runs it on every change. + +## Rendering — `render.py` (the ServiceDefinition renderer, T-0.3) +[`render.py`](./render.py) compiles a ServiceDefinition into the concrete manifest set defined +by [`../schemas/renderer-contract.md`](../schemas/renderer-contract.md): a Namespace (with the +`app-namespace`/`gateway-access` labels), the default-deny baseline (R-NET-01), a +Deployment+Service **or** a KubeVirt VirtualMachine + CDI DataVolume (R-PLAT-02/03), PVCs +(R-STATE), one CiliumNetworkPolicy egress-allow per `dependency` (R-NET-02), the +`egress-class` steering label (R-NET-05), HTTPRoute(s) on the shared Gateway (R-TLS/R-DNS), +ServiceMonitor + SLI alerts (R-OBS-01), the Flux ImageRepository/ImagePolicy update plumbing +(R-UPD), and the auth-mode / backup wiring hints (R-IDP/R-STATE-03). + +```bash +python3 catalog/render.py catalog/pihole.yaml # render one SD to stdout +python3 catalog/render.py --all catalog -o out/ # render every SD to out/.yaml +python3 catalog/render.py --selftest # deterministic self-test (no cluster) +``` + +- **Deterministic:** identical input → byte-identical output (golden-diffable). The L1 render + conformance tests (`conformance/tests/l1-rendering/*`) diff the output against committed + goldens — run offline by `conformance/run.sh --layer l1` and `ci/l1.sh` (no cluster). +- **How CI/Flux consume it:** CI invokes `render.py` in the L1 stage; a GitOps flow renders + `--all catalog -o ` and Flux reconciles the rendered directory. Design choice + (renderer-contract is silent on mechanism): a render-then-consume script is the simplest + reproducible option — see the header of `render.py`. +- **Dependency:** PyYAML (the renderer's single dependency; the conformance harness stays + stdlib-only and only shells out to this script + diffs text). diff --git a/catalog/render.py b/catalog/render.py new file mode 100644 index 0000000..fa2feaa --- /dev/null +++ b/catalog/render.py @@ -0,0 +1,774 @@ +#!/usr/bin/env python3 +"""ServiceDefinition renderer (task T-0.3) — the platform's core GitOps contract. + +Consumes ONE ServiceDefinition (schemas/servicedefinition.schema.json, catalog/*.yaml) +and emits the concrete manifest set the renderer contract specifies +(schemas/renderer-contract.md): Namespace + default-deny baseline, Deployment/Service or +KubeVirt VirtualMachine + CDI DataVolume, PVCs, per-dependency CiliumNetworkPolicy allows, +egress-class steering label, HTTPRoute(s), ServiceMonitor/alerts, Flux image-automation, +and the auth-mode / backup wiring hints. + +Requirements: R-GITOPS-02 (single reviewable object wires every requirement), R-NET-01/02 +(default-deny + explicit allows), R-NET-05 (egress class), R-STATE-01/02/04 (storage), +R-TLS/R-DNS (ingressRoutes/exposure), R-OBS-01 (observability), R-UPD-01/02/03 (update), +R-IDP-02/06 (auth), R-PLAT-02/03 (kind/source). + +Design (documented per the task's "if the contract is ambiguous, choose the simplest +reproducible option"): a deterministic Python renderer. The contract does not mandate a +mechanism (script / kustomize generator / templates); a render-then-consume script is the +simplest reproducible option the repo can run in CI and that Flux consumes from a rendered +output path. CI invokes it via `render.py ` (or `--all`); the offline L1 render +conformance verifier diffs its deterministic output against committed goldens. + +Determinism: fixed document order + fixed key order + PyYAML with sort_keys=False and a +wide line width, so identical input always yields byte-identical output (golden-diffable). + +Dependency: PyYAML (input parse + output emit). This is the ONE renderer dependency; the +conformance harness stays stdlib-only and only shells out to this script + diffs text. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +try: + import yaml +except ImportError: # pragma: no cover - explicit, actionable failure + sys.stderr.write( + "render.py requires PyYAML (the renderer's single dependency). " + "Install with: pip install pyyaml\n" + ) + raise SystemExit(2) + +# --- Label / naming conventions (match platform/kyverno/policies + platform/*/) -------- +LBL = "homelab.keithjackson.dev" +ZONE = "home.keithjackson.dev" +GATEWAY_NAME = "shared" +GATEWAY_NS = "gateway" +KP_RELEASE = "kube-prometheus-stack" # ServiceMonitor/PrometheusRule discovery label + +# egress classes and auth modes fixed by docs/03 / docs/13 + the Kyverno enums. +EGRESS_CLASSES = ("internet", "vpn") +AUTH_MODES = ("oidc", "forward-auth", "native", "none") + + +# ============================================================================= +# Defaults (renderer-contract.md "Default posture") — materialize BEFORE mapping. +# ============================================================================= +def apply_defaults(sd: dict) -> dict: + """Return a copy of the ServiceDefinition with the contract's default posture filled. + + A field absent from a ServiceDefinition renders from its default posture, not "not at + all" (renderer-contract.md). + """ + s = dict(sd) + s.setdefault("kind", "container") + s.setdefault("exposure", "internal") + s.setdefault("egress", "internet") + s.setdefault("storage", []) + s.setdefault("dependencies", []) + s.setdefault("ingressRoutes", []) + + # auth.mode default posture: forward-auth for any exposed UI, else none (internal, no + # UI). An explicit auth block always wins (R-IDP-02). + auth = dict(s.get("auth") or {}) + if "mode" not in auth: + auth["mode"] = "forward-auth" if s["ingressRoutes"] else "none" + auth.setdefault("groups", []) + s["auth"] = auth + + update = dict(s.get("update") or {}) + update.setdefault("channel", "stable") + update.setdefault("autoRollbackOnFailedHealth", True) + s["update"] = update + + obs = dict(s.get("observability") or {}) + obs.setdefault("metrics", True) + obs.setdefault("slis", []) + s["observability"] = obs + + # storage entry class default is longhorn (replicated tier). + s["storage"] = [{**{"class": "longhorn"}, **v} for v in s["storage"]] + # dependency protocol default is tcp. + s["dependencies"] = [{**{"protocol": "tcp"}, **d} for d in s["dependencies"]] + return s + + +# ============================================================================= +# Field -> resource mapping (renderer-contract.md "Field -> resource mapping") +# ============================================================================= +def r_namespace(s: dict) -> dict: + """Namespace = the service trust zone / isolation boundary (R-NET, docs/03). + + Carries app-namespace=true so Kyverno generates the default-deny baseline and the + egress-class / auth-mode admission policies apply. gateway-access=shared lets its + HTTPRoutes attach to the shared Gateway (only when it exposes routes). + """ + labels = {f"{LBL}/app-namespace": "true"} + if s["ingressRoutes"]: + labels["gateway-access"] = "shared" + ann = {} + backup = (s.get("backup") or {}).get("policy") + if backup: + ann[f"{LBL}/backup-policy"] = backup # R-STATE-03 off-cluster schedule ref (hint) + meta = {"name": s["name"], "labels": labels} + if ann: + meta["annotations"] = ann + return {"apiVersion": "v1", "kind": "Namespace", "metadata": meta} + + +def r_default_deny(s: dict) -> dict: + """The default-deny baseline: deny all ingress+egress, allow ONLY DNS egress (R-NET-01). + + Rendered explicitly (GitOps at namespace creation, renderer-contract §dependencies) — + it matches the Kyverno-generated baseline shape (platform/kyverno/.../generated-default + -deny.yaml) so the render carries the guarantee even before admission generates it. + """ + return { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "default-deny", + "namespace": s["name"], + "labels": {f"{LBL}/baseline": "default-deny"}, + }, + "spec": { + "podSelector": {}, + "policyTypes": ["Ingress", "Egress"], + "ingress": [], + "egress": [ + { + "to": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "kube-system" + } + } + } + ], + "ports": [ + {"protocol": "UDP", "port": 53}, + {"protocol": "TCP", "port": 53}, + ], + } + ], + }, + } + + +def _pod_labels(s: dict) -> dict: + """Workload pod labels: identity + the egress-class steering hook + auth-mode. + + egress-class is what the shared Cilium Egress Gateway selects on to steer vpn-class + pods (enforcement is OUTSIDE the workload — renderer-contract §egress; ADR-0015); + auth-mode satisfies the Kyverno require-auth-mode-label policy (R-IDP-02). + """ + return { + "app.kubernetes.io/name": s["name"], + f"{LBL}/egress-class": s["egress"], + f"{LBL}/auth-mode": s["auth"]["mode"], + } + + +def _service_ports(s: dict) -> list: + ports = sorted({r["port"] for r in s["ingressRoutes"]}) + return [{"name": f"port-{p}", "port": p, "targetPort": p} for p in ports] + + +def r_pvcs(s: dict) -> list: + """One PVC per storage entry (R-STATE-01/02/04). + + longhorn -> 3-replica replicated tier; bulk -> external media tier (not 3x replicated). + """ + out = [] + for vol in s["storage"]: + cls = vol.get("class", "longhorn") + storage_class = "longhorn" if cls == "longhorn" else "bulk" + spec = { + "accessModes": ["ReadWriteOnce"], + "storageClassName": storage_class, + } + if vol.get("size"): + spec["resources"] = {"requests": {"storage": vol["size"]}} + out.append( + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "name": f"{s['name']}-{vol['name']}", + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": spec, + } + ) + return out + + +def r_deployment(s: dict) -> dict: + """Deployment for a container workload (R-PLAT-02).""" + pod_labels = _pod_labels(s) + container = { + "name": s["name"], + "image": s["source"]["image"], + } + ports = _service_ports(s) + if ports: + container["ports"] = [{"containerPort": p["port"]} for p in ports] + + volume_mounts, volumes = [], [] + for vol in s["storage"]: + volume_mounts.append({"name": vol["name"], "mountPath": f"/data/{vol['name']}"}) + volumes.append( + { + "name": vol["name"], + "persistentVolumeClaim": {"claimName": f"{s['name']}-{vol['name']}"}, + } + ) + if volume_mounts: + container["volumeMounts"] = volume_mounts + + pod_spec = {"containers": [container]} + if volumes: + pod_spec["volumes"] = volumes + + ann = _auth_annotations(s) + tmpl_meta = {"labels": pod_labels} + if ann: + tmpl_meta["annotations"] = ann + + return { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": s["name"], + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "replicas": 1, + "selector": {"matchLabels": {"app.kubernetes.io/name": s["name"]}}, + "template": {"metadata": tmpl_meta, "spec": pod_spec}, + }, + } + + +def r_service(s: dict): + ports = _service_ports(s) + if not ports: + return None + return { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": s["name"], + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "selector": {"app.kubernetes.io/name": s["name"]}, + "ports": ports, + }, + } + + +def r_dependency_policies(s: dict) -> list: + """One CiliumNetworkPolicy egress allow per dependency, on top of default-deny (R-NET-02). + + Everything undeclared stays denied; empty dependencies => reaches only DNS. + """ + out = [] + for dep in s["dependencies"]: + proto = dep.get("protocol", "tcp").upper() + out.append( + { + "apiVersion": "cilium.io/v2", + "kind": "CiliumNetworkPolicy", + "metadata": { + "name": f"{s['name']}-allow-{dep['to']}-{dep['port']}", + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "endpointSelector": { + "matchLabels": {"app.kubernetes.io/name": s["name"]} + }, + "egress": [ + { + "toEndpoints": [ + {"matchLabels": {"app.kubernetes.io/name": dep["to"]}} + ], + "toPorts": [ + { + "ports": [ + {"port": str(dep["port"]), "protocol": proto} + ] + } + ], + } + ], + }, + } + ) + return out + + +def _route_meta(s: dict, route: dict) -> dict: + meta = { + "name": f"{s['name']}-{route['port']}", + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + } + ann = _auth_annotations(s) + if ann: + meta["annotations"] = ann + return meta + + +def r_httproutes(s: dict) -> list: + """One HTTPRoute per ingressRoute, attaching host -> service port on the shared Gateway. + + TLS is the Gateway's wildcard *.home.keithjackson.dev cert (no per-service cert step); + ExternalDNS manages the record from the HTTPRoute (R-TLS/R-DNS, renderer-contract). + """ + out = [] + for route in s["ingressRoutes"]: + out.append( + { + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": _route_meta(s, route), + "spec": { + "parentRefs": [ + { + "name": GATEWAY_NAME, + "namespace": GATEWAY_NS, + "sectionName": "https", + } + ], + "hostnames": [route["host"]], + "rules": [ + { + "matches": [ + {"path": {"type": "PathPrefix", "value": "/"}} + ], + "backendRefs": [ + {"name": s["name"], "port": route["port"]} + ], + } + ], + }, + } + ) + return out + + +def _auth_annotations(s: dict) -> dict: + """Auth wiring hints (renderer-contract §auth): the platform (oauth2-proxy/Keycloak) + consumes these to protect ingressRoutes. oidc/forward-auth carry the authorized groups + (R-IDP-06: access is 'in an authorized group', not merely 'logged in').""" + mode = s["auth"]["mode"] + if mode in ("oidc", "forward-auth"): + groups = ",".join(s["auth"].get("groups", [])) + return { + f"{LBL}/auth-mode": mode, + f"{LBL}/auth-groups": groups, + } + return {} + + +def r_servicemonitor(s: dict): + if not s["observability"]["metrics"]: + return None + return { + "apiVersion": "monitoring.coreos.com/v1", + "kind": "ServiceMonitor", + "metadata": { + "name": s["name"], + "namespace": s["name"], + "labels": {"release": KP_RELEASE, "app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "selector": {"matchLabels": {"app.kubernetes.io/name": s["name"]}}, + "namespaceSelector": {"matchNames": [s["name"]]}, + "endpoints": [{"port": "metrics", "interval": "30s"}], + }, + } + + +def r_prometheusrule(s: dict): + """Each SLI becomes at least one alert rule (R-OBS-01).""" + slis = s["observability"]["slis"] + if not slis: + return None + rules = [] + for sli in slis: + if sli == "up": + rules.append( + { + "alert": f"{s['name']}-down".replace("_", "-"), + "expr": f'up{{job="{s["name"]}"}} == 0', + "for": "5m", + "labels": {"severity": "critical", "service": s["name"]}, + "annotations": { + "summary": f"{s['name']} is down", + "description": f"{s['name']} has been unreachable for 5m (SLI: up).", + }, + } + ) + else: + rules.append( + { + "alert": f"{s['name']}-{sli}".replace("_", "-"), + "expr": f'absent({sli}{{service="{s["name"]}"}})', + "for": "10m", + "labels": {"severity": "warning", "service": s["name"]}, + "annotations": {"summary": f"{s['name']} SLI {sli} unhealthy"}, + } + ) + return { + "apiVersion": "monitoring.coreos.com/v1", + "kind": "PrometheusRule", + "metadata": { + "name": f"{s['name']}-slis", + "namespace": s["name"], + "labels": {"release": KP_RELEASE, "app.kubernetes.io/name": s["name"]}, + }, + "spec": {"groups": [{"name": f"{s['name']}.slis", "rules": rules}]}, + } + + +def r_image_automation(s: dict) -> list: + """container kind -> Flux ImageRepository + ImagePolicy encoding channel/range (R-UPD). + + Flux image-automation advances the running version on its scan interval; no human/AI + edits a tag (R-UPD-02). `pin` freezes automation. vm kind uses in-guest updates instead. + """ + if s["kind"] != "container": + return [] + image = s["source"]["image"].split(":", 1)[0] + upd = s["update"] + interval = "1h0m0s" + imagerepo = { + "apiVersion": "image.toolkit.fluxcd.io/v1beta2", + "kind": "ImageRepository", + "metadata": {"name": s["name"], "namespace": s["name"]}, + "spec": {"image": image, "interval": interval}, + } + policy_spec: dict = {"imageRepositoryRef": {"name": s["name"]}} + if upd.get("pin"): + # Operator freeze escape hatch: pin to a fixed tag, stopping automation. + policy_spec["filterTags"] = {"pattern": f"^{upd['pin']}$"} + policy_spec["policy"] = {"alphabetical": {"order": "asc"}} + elif upd.get("range"): + policy_spec["policy"] = {"semver": {"range": upd["range"]}} + else: + policy_spec["policy"] = {"semver": {"range": ">=0.0.0"}} + imagepolicy = { + "apiVersion": "image.toolkit.fluxcd.io/v1beta2", + "kind": "ImagePolicy", + "metadata": { + "name": s["name"], + "namespace": s["name"], + "annotations": { + f"{LBL}/update-channel": upd.get("channel", "stable"), + f"{LBL}/auto-rollback": str( + upd.get("autoRollbackOnFailedHealth", True) + ).lower(), + }, + }, + "spec": policy_spec, + } + return [imagerepo, imagepolicy] + + +# --- VM (kind: vm) path: KubeVirt VirtualMachine + CDI DataVolume ---------------------- +def r_datavolume(s: dict) -> dict: + """Standalone CDI DataVolume (NOT a dataVolumeTemplate) so the disk OUTLIVES the VM + and survives destroy/rebuild (R-STATE-01) — matches the vm-lifecycle fixture shape.""" + root = s["storage"][0] if s["storage"] else {"name": "rootdisk", "size": "10Gi"} + return { + "apiVersion": "cdi.kubevirt.io/v1beta1", + "kind": "DataVolume", + "metadata": { + "name": f"{s['name']}-{root['name']}", + "namespace": s["name"], + }, + "spec": { + "source": {"registry": {"url": f"docker://{s['source']['vmTemplate']}"}}, + "storage": { + "accessModes": ["ReadWriteOnce"], + "storageClassName": "longhorn", + "resources": {"requests": {"storage": root.get("size", "10Gi")}}, + }, + }, + } + + +VM_CLOUD_INIT = ( + "#cloud-config\n" + "package_update: true\n" + "packages:\n" + " - dnf-automatic\n" + "write_files:\n" + " - path: /etc/dnf/automatic.conf\n" + " content: |\n" + " [commands]\n" + " upgrade_type = default\n" + " random_sleep = 0\n" + " download_updates = yes\n" + " apply_updates = yes\n" + "runcmd:\n" + " - [ systemctl, enable, --now, dnf-automatic.timer ]\n" +) + + +def r_virtualmachine(s: dict) -> dict: + """KubeVirt VirtualMachine; egress-class propagates to the virt-launcher pod so the + shared Cilium egress steering + Kyverno policy see the class (R-PLAT-03; ADR-0015). + In-guest dnf-automatic delivers auto-updates with no human step (R-UPD-01, docs/11).""" + root = s["storage"][0] if s["storage"] else {"name": "rootdisk"} + pod_labels = { + "kubevirt.io/vm": s["name"], + f"{LBL}/egress-class": s["egress"], + f"{LBL}/auth-mode": s["auth"]["mode"], + } + return { + "apiVersion": "kubevirt.io/v1", + "kind": "VirtualMachine", + "metadata": { + "name": s["name"], + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "running": False, + "template": { + "metadata": {"labels": pod_labels}, + "spec": { + "domain": { + "cpu": {"cores": 1}, + "memory": {"guest": "1536Mi"}, + "devices": { + "disks": [ + {"name": "rootdisk", "disk": {"bus": "virtio"}}, + {"name": "cloudinit", "disk": {"bus": "virtio"}}, + ], + "interfaces": [{"name": "default", "masquerade": {}}], + }, + "resources": {"requests": {"memory": "1536Mi"}}, + }, + "networks": [{"name": "default", "pod": {}}], + "volumes": [ + { + "name": "rootdisk", + "dataVolume": {"name": f"{s['name']}-{root['name']}"}, + }, + { + "name": "cloudinit", + "cloudInitNoCloud": {"userData": VM_CLOUD_INIT}, + }, + ], + }, + }, + }, + } + + +# ============================================================================= +# Top-level render + emit +# ============================================================================= +def render(sd: dict) -> list: + """Return an ordered list of (header_comment, resource_dict) for a ServiceDefinition.""" + s = apply_defaults(sd) + if s["kind"] not in ("container", "vm"): + raise ValueError(f"unknown kind: {s['kind']!r}") + if s["egress"] not in EGRESS_CLASSES: + raise ValueError(f"invalid egress class: {s['egress']!r}") + if s["auth"]["mode"] not in AUTH_MODES: + raise ValueError(f"invalid auth.mode: {s['auth']['mode']!r}") + + docs: list = [] + docs.append(("Namespace (trust zone; default-deny + policies apply)", r_namespace(s))) + docs.append(("Default-deny baseline (R-NET-01): deny all, allow only DNS", r_default_deny(s))) + + if s["kind"] == "container": + # Container PVCs. (For a vm, the CDI DataVolume owns/creates the disk PVC, so we + # never render a plain PVC for the VM root disk — it would collide with CDI.) + for pvc in r_pvcs(s): + docs.append((f"PVC {pvc['metadata']['name']} (R-STATE)", pvc)) + docs.append(("Deployment (R-PLAT-02)", r_deployment(s))) + svc = r_service(s) + if svc: + docs.append(("Service", svc)) + else: + docs.append(("CDI DataVolume (standalone; survives rebuild, R-STATE-01)", r_datavolume(s))) + docs.append(("KubeVirt VirtualMachine (R-PLAT-03)", r_virtualmachine(s))) + + for cnp in r_dependency_policies(s): + docs.append((f"CiliumNetworkPolicy egress allow (R-NET-02): {cnp['metadata']['name']}", cnp)) + + for hr in r_httproutes(s): + docs.append((f"HTTPRoute {hr['metadata']['name']} (R-TLS/R-DNS)", hr)) + + sm = r_servicemonitor(s) + if sm: + docs.append(("ServiceMonitor (R-OBS-01)", sm)) + pr = r_prometheusrule(s) + if pr: + docs.append(("PrometheusRule SLI alerts (R-OBS-01)", pr)) + + for res in r_image_automation(s): + docs.append((f"Flux {res['kind']} (R-UPD)", res)) + + return docs + + +def emit(docs: list) -> str: + """Deterministic multi-document YAML text (golden-diffable).""" + parts = [] + for comment, resource in docs: + body = yaml.dump( + resource, + sort_keys=False, + default_flow_style=False, + width=4096, + ) + parts.append(f"---\n# {comment}\n{body}".rstrip() + "\n") + return "".join(parts) + + +def load_sd(path: Path) -> dict: + with path.open() as fh: + return yaml.safe_load(fh) + + +def render_file(path: Path) -> str: + return emit(render(load_sd(path))) + + +# ============================================================================= +# Self-test (deterministic, no cluster) — `render.py --selftest` +# ============================================================================= +def _selftest() -> int: + failures = [] + + def check(cond, msg): + if not cond: + failures.append(msg) + + # 1) A minimal container SD renders the mandatory contract invariants. + sd = {"name": "demo", "source": {"image": "nginx:1"}} + docs = render(sd) + kinds = [d[1]["kind"] for d in docs] + check("Namespace" in kinds, "container: missing Namespace") + check("NetworkPolicy" in kinds, "container: missing default-deny NetworkPolicy") + check("Deployment" in kinds, "container: missing Deployment") + + ns = next(d[1] for d in docs if d[1]["kind"] == "Namespace") + check( + ns["metadata"]["labels"].get(f"{LBL}/app-namespace") == "true", + "namespace missing app-namespace=true (Kyverno hook)", + ) + dep = next(d[1] for d in docs if d[1]["kind"] == "Deployment") + plabels = dep["spec"]["template"]["metadata"]["labels"] + check( + plabels.get(f"{LBL}/egress-class") == "internet", + "default egress-class must be internet (R-NET-05)", + ) + check( + plabels.get(f"{LBL}/auth-mode") in AUTH_MODES, + "pod must carry a valid auth-mode label (R-IDP-02)", + ) + ndp = next(d[1] for d in docs if d[1]["kind"] == "NetworkPolicy") + check( + ndp["spec"]["policyTypes"] == ["Ingress", "Egress"], + "default-deny must cover both directions (R-NET-01)", + ) + check(ndp["spec"]["ingress"] == [], "default-deny must deny all ingress (R-NET-01)") + + # 2) egress: vpn flips ONLY the egress-class label (R-NET-07). + internet_docs = render({"name": "x", "source": {"image": "a:1"}, "egress": "internet"}) + vpn_docs = render({"name": "x", "source": {"image": "a:1"}, "egress": "vpn"}) + i_pod = next(d[1] for d in internet_docs if d[1]["kind"] == "Deployment") + v_pod = next(d[1] for d in vpn_docs if d[1]["kind"] == "Deployment") + check( + i_pod["spec"]["template"]["metadata"]["labels"][f"{LBL}/egress-class"] == "internet" + and v_pod["spec"]["template"]["metadata"]["labels"][f"{LBL}/egress-class"] == "vpn", + "egress class must render onto the pod label", + ) + # Everything except the egress-class label must be identical (R-NET-07). + i_txt = emit(internet_docs).replace("egress-class: internet", "EGRESS") + v_txt = emit(vpn_docs).replace("egress-class: vpn", "EGRESS") + check(i_txt == v_txt, "flipping egress class changed more than the egress path (R-NET-07)") + + # 3) Determinism: same input twice -> byte-identical output. + check(emit(render(sd)) == emit(render(sd)), "render is not deterministic") + + # 4) dependencies -> one CiliumNetworkPolicy allow each (R-NET-02). + dep_docs = render( + { + "name": "y", + "source": {"image": "a:1"}, + "dependencies": [{"to": "db", "port": 5432}], + } + ) + cnps = [d[1] for d in dep_docs if d[1]["kind"] == "CiliumNetworkPolicy"] + check(len(cnps) == 1, "one dependency must render one CiliumNetworkPolicy allow") + + # 5) vm kind -> VirtualMachine + standalone DataVolume. + vm_docs = render( + { + "name": "vmx", + "kind": "vm", + "source": {"vmTemplate": "quay.io/containerdisks/fedora:41"}, + "storage": [{"name": "rootdisk", "size": "6Gi", "class": "longhorn"}], + } + ) + vm_kinds = [d[1]["kind"] for d in vm_docs] + check("VirtualMachine" in vm_kinds, "vm: missing VirtualMachine") + check("DataVolume" in vm_kinds, "vm: missing CDI DataVolume") + + if failures: + for f in failures: + sys.stderr.write(f"SELFTEST FAIL: {f}\n") + return 1 + sys.stderr.write("render.py selftest: PASS\n") + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description="Render a ServiceDefinition to manifests.") + ap.add_argument("input", nargs="?", help="ServiceDefinition YAML file") + ap.add_argument("--all", metavar="DIR", help="Render every *.yaml ServiceDefinition in DIR") + ap.add_argument("-o", "--out-dir", help="With --all: write .yaml files into this dir") + ap.add_argument("--selftest", action="store_true", help="Run the built-in self-test") + args = ap.parse_args(argv) + + if args.selftest: + return _selftest() + + if args.all: + src = Path(args.all) + files = sorted(p for p in src.glob("*.yaml") if p.name != "render.py") + for f in files: + text = render_file(f) + if args.out_dir: + outdir = Path(args.out_dir) + outdir.mkdir(parents=True, exist_ok=True) + name = load_sd(f)["name"] + (outdir / f"{name}.yaml").write_text(text) + else: + sys.stdout.write(text) + return 0 + + if not args.input: + ap.error("provide a ServiceDefinition file, --all DIR, or --selftest") + sys.stdout.write(render_file(Path(args.input))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4a017c590e337a933223d06daa716b9d005b21cd Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 24 Aug 2026 22:19:08 -0500 Subject: [PATCH 2/3] Wire L1 render conformance tests to the renderer (R-NET-01, R-NET-05/06/07) Implements the two L1 rendering tests that were failing scaffolds: - egress-class-render (R-NET-05/06/07): renders vpn + internet fixtures, diffs each against a committed golden, asserts the vpn steering label is present on vpn and absent on internet (fail-closed), and that flipping the class changes ONLY the egress path (R-NET-07). This fixes the --requirements R-NET-05 merge gate. - default-deny-baseline-render (R-NET-01): asserts every render carries the namespaced default-deny baseline, shaped deny-all + both directions. Each test carries a pure-offline render-check.sh (renderer + golden diff, no cluster). run.sh runs L1 render checks offline and emits a REAL pass/fail JUnit (docs/14: L1 rendering assertions run in CI without a cluster) instead of skipping L1 with the cluster-gated layers; the chainsaw-test.yaml wrappers invoke the same render-check.sh so an in-cluster run executes the identical assertion. Co-Authored-By: Claude Opus 4.8 --- conformance/lib/run_l1_offline.py | 112 ++++++++++ conformance/run.sh | 46 +++- .../chainsaw-test.yaml | 17 +- .../fixtures/svcdef.yaml | 8 + .../golden/expected.yaml | 103 +++++++++ .../render-check.sh | 51 +++++ .../egress-class-render/chainsaw-test.yaml | 29 ++- .../fixtures/svcdef-egress-internet.yaml | 30 +++ .../fixtures/svcdef-egress-vpn.yaml | 31 +++ .../golden/egress-internet.expected.yaml | 200 ++++++++++++++++++ .../golden/egress-vpn.expected.yaml | 200 ++++++++++++++++++ .../egress-class-render/render-check.sh | 50 +++++ 12 files changed, 851 insertions(+), 26 deletions(-) create mode 100644 conformance/lib/run_l1_offline.py create mode 100644 conformance/tests/l1-rendering/default-deny-baseline-render/fixtures/svcdef.yaml create mode 100644 conformance/tests/l1-rendering/default-deny-baseline-render/golden/expected.yaml create mode 100755 conformance/tests/l1-rendering/default-deny-baseline-render/render-check.sh create mode 100644 conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-internet.yaml create mode 100644 conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-vpn.yaml create mode 100644 conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml create mode 100644 conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml create mode 100755 conformance/tests/l1-rendering/egress-class-render/render-check.sh diff --git a/conformance/lib/run_l1_offline.py b/conformance/lib/run_l1_offline.py new file mode 100644 index 0000000..d2f6155 --- /dev/null +++ b/conformance/lib/run_l1_offline.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Run the L1 *rendering* conformance checks OFFLINE (no cluster) and emit JUnit. + +docs/14: "L1 rendering assertions run in CI without a cluster." L1 verifies the +ServiceDefinition -> rendered-resources mapping against golden expectations; unlike L2/L3 +it needs no live cluster. Each l1-rendering test dir carries a `render-check.sh` (renderer ++ golden diff + structural asserts). run.sh invokes this for `--layer l1` so those tests +report a REAL pass/fail instead of being skipped with the cluster-gated layers. + +Pure standard library (matches the harness's no-pip-dependency posture); the render checks +themselves shell out to catalog/render.py (the renderer's PyYAML dependency lives there). + +Usage: run_l1_offline.py --junit [--label-prefix P] [ ...] +Exit 0 iff every selected render check passes. +""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from xml.dom import minidom + +LABEL_LINE_RE_TMPL = r"^\s*{prefix}/(R-[A-Z0-9]+-[0-9]+)\s*:\s*[\"']?true" + + +def collect_meta(test_yaml: Path, prefix: str): + text = test_yaml.read_text() if test_yaml.exists() else "" + name_m = re.search(r"^\s*name:\s*(\S+)", text, re.M) + name = name_m.group(1) if name_m else test_yaml.parent.name + rx = re.compile(LABEL_LINE_RE_TMPL.format(prefix=re.escape(prefix)), re.I) + reqs = sorted({m.group(1).upper() for m in (rx.match(l) for l in text.splitlines()) if m}) + return name, reqs + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--junit", type=Path, required=True) + ap.add_argument("--label-prefix", default="conformance.home.k3s") + ap.add_argument("dirs", nargs="+", type=Path) + args = ap.parse_args() + + cases = [] # (name, reqs, dir, rc, output) + for d in args.dirs: + check = d / "render-check.sh" + name, reqs = collect_meta(d / "chainsaw-test.yaml", args.label_prefix) + if not check.exists(): + cases.append((name, reqs, d, 3, f"no render-check.sh in {d}")) + continue + proc = subprocess.run( + ["bash", str(check)], capture_output=True, text=True + ) + out = (proc.stdout + proc.stderr).strip() + cases.append((name, reqs, d, proc.returncode, out)) + + failures = sum(1 for c in cases if c[3] not in (0, 3)) + skipped = sum(1 for c in cases if c[3] == 3) + + suite = ET.Element( + "testsuite", + { + "name": "home-k3s-conformance L1 (offline render)", + "tests": str(len(cases)), + "failures": str(failures), + "errors": "0", + "skipped": str(skipped), + }, + ) + for name, reqs, d, rc, out in cases: + tc = ET.SubElement( + suite, + "testcase", + {"classname": f"L1:{','.join(reqs) if reqs else 'UNTAGGED'}", "name": name}, + ) + props = ET.SubElement(tc, "properties") + ET.SubElement(props, "property", {"name": "requirements", "value": ",".join(reqs)}) + ET.SubElement(props, "property", {"name": "layer", "value": "L1"}) + ET.SubElement(props, "property", {"name": "path", "value": str(d)}) + if rc == 3: + ET.SubElement(tc, "skipped", {"message": out or "no render-check.sh"}) + elif rc != 0: + fail = ET.SubElement(tc, "failure", {"message": f"render-check exit {rc}"}) + fail.text = out + status = "PASS" if rc == 0 else ("SKIP" if rc == 3 else "FAIL") + print(f"[l1-offline] {status:4s} {name} ({','.join(reqs) or 'untagged'})", + file=sys.stderr) + if rc not in (0, 3): + print(out, file=sys.stderr) + + root = ET.Element( + "testsuites", + { + "name": "home-k3s-conformance", + "tests": str(len(cases)), + "failures": str(failures), + "errors": "0", + "skipped": str(skipped), + }, + ) + root.append(suite) + args.junit.parent.mkdir(parents=True, exist_ok=True) + xml = minidom.parseString(ET.tostring(root, "utf-8")).toprettyxml(indent=" ") + args.junit.write_text(xml) + print(f"[l1-offline] wrote JUnit ({len(cases)} case(s), {failures} failure(s)) -> " + f"{args.junit}", file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/conformance/run.sh b/conformance/run.sh index fdcafc9..bea1715 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -169,6 +169,28 @@ run_chainsaw() { fi } +# --- L1 rendering: cluster-INDEPENDENT render checks (docs/14) -------------------------- +# L1 verifies ServiceDefinition -> rendered-resources against golden expectations; it needs +# no live cluster. Each l1-rendering test dir carries a `render-check.sh` (renderer + golden +# diff + structural asserts). We run those directly and emit a REAL pass/fail JUnit, instead +# of skipping L1 with the cluster-gated layers (T-0.3). Honors the same --layer/--requirements +# selection as the rest of the suite (select_dirs), filtered to the l1-rendering subtree. +l1_selected_dirs() { + select_dirs | grep '/l1-rendering/' || true +} + +run_l1_offline() { + local -a l1dirs + mapfile -t l1dirs < <(l1_selected_dirs) + if [ "${#l1dirs[@]}" -eq 0 ]; then + log "No L1 render tests match the selection (layer=${LAYER} reqs='${REQS}')." + return 0 + fi + log "Running ${#l1dirs[@]} L1 render check(s) OFFLINE (no cluster; docs/14)." + "${PYTHON}" "${CONF_ROOT}/lib/run_l1_offline.py" \ + --junit "${JUNIT_OUT}" --label-prefix "${CONF_LABEL_PREFIX}" "${l1dirs[@]}" +} + ############################################################################### # Main ############################################################################### @@ -187,9 +209,29 @@ case "${MODE}" in ;; esac -# run mode: need a cluster for L1/L2/L3. Without one (Phase 0), emit skipped JUnit. +# L1 rendering is cluster-independent (docs/14): run the selected L1 render checks offline +# and treat their result as authoritative REGARDLESS of cluster availability. A render-check +# failure is a real failure (nothing cluster-gated about it), so it always blocks. +L1_RC=0 +run_l1_offline || L1_RC=$? +if [ "${LAYER}" = l1 ]; then + # L1-only run (the ci/l1.sh merge-gate path): the offline render result IS the result. + if [ "${L1_RC}" -eq 0 ]; then + log "Done (L1 render). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md" + else + log "L1 render checks FAILED (rc=${L1_RC}). JUnit: ${JUNIT_OUT}" + fi + exit "${L1_RC}" +fi +if [ "${L1_RC}" -ne 0 ]; then + log "L1 render checks FAILED (rc=${L1_RC}) — blocking (render failure is not cluster-gated)." + exit "${L1_RC}" +fi + +# run mode: need a cluster for L2/L3. Without one (Phase 0), emit skipped JUnit for the +# cluster-gated layers. (L1 already ran offline above and passed.) if [ "${FORCE_NO_CLUSTER}" = 1 ] || ! conf_cluster_reachable; then - log "No reachable cluster — emitting SKIPPED JUnit scaffold (Phase 0 behavior)." + log "No reachable cluster — emitting SKIPPED JUnit scaffold for L2/L3 (Phase 0 behavior)." log "Also running structural lint so scaffolds stay valid." lint_tests || { log "lint failed"; exit 1; } "${PYTHON}" "${CONF_ROOT}/lib/emit_skipped_junit.py" \ diff --git a/conformance/tests/l1-rendering/default-deny-baseline-render/chainsaw-test.yaml b/conformance/tests/l1-rendering/default-deny-baseline-render/chainsaw-test.yaml index e254541..ea46220 100644 --- a/conformance/tests/l1-rendering/default-deny-baseline-render/chainsaw-test.yaml +++ b/conformance/tests/l1-rendering/default-deny-baseline-render/chainsaw-test.yaml @@ -11,7 +11,11 @@ # the L0 Kyverno gate (T-0.5). Rendering the baseline is necessary but NOT # sufficient — enforcement is proven behaviorally at L2 (R-TEST-01). # -# STATUS: scaffold — needs T-0.3 (renderer) + T-2.2 (baseline definition). +# STATUS: IMPLEMENTED (T-0.3). render-check.sh renders a service and asserts the +# namespaced default-deny baseline is present + shaped (deny-all, both directions) — +# a pure offline check (renderer + golden diff, no cluster). conformance/run.sh --layer +# l1 and ci/l1.sh run it directly; this Chainsaw Test wraps the SAME script so an +# in-cluster run executes the identical assertion. Rendering != enforcement (proven L2). # ============================================================================= apiVersion: chainsaw.kyverno.io/v1alpha1 kind: Test @@ -27,11 +31,10 @@ metadata: Rendering any service's namespace yields a default-deny baseline policy. spec: steps: - - name: given-a-rendered-namespace + - name: render-and-assert-default-deny + description: >- + Render a service and assert the namespaced default-deny baseline is present and + shaped (deny-all ingress, both Ingress+Egress), matching the golden. try: - script: - content: 'echo "TODO(T-0.3/T-2.2): render a service; capture namespaced policies"' - - name: then-render-includes-default-deny - try: - - assert: - file: golden/default-deny-baseline.expected.yaml # TODO: author golden + content: bash ./render-check.sh diff --git a/conformance/tests/l1-rendering/default-deny-baseline-render/fixtures/svcdef.yaml b/conformance/tests/l1-rendering/default-deny-baseline-render/fixtures/svcdef.yaml new file mode 100644 index 0000000..7acbddf --- /dev/null +++ b/conformance/tests/l1-rendering/default-deny-baseline-render/fixtures/svcdef.yaml @@ -0,0 +1,8 @@ +# L1 fixture: a minimal ServiceDefinition. Any onboarded service — regardless of its +# dependencies — must render the namespaced default-deny baseline (R-NET-01). This fixture +# declares NO dependencies, so its render must still carry the deny-all + DNS-only baseline. +# Validates against schemas/servicedefinition.schema.json. +name: baseline-demo +kind: container +source: + image: nginx:1.27 diff --git a/conformance/tests/l1-rendering/default-deny-baseline-render/golden/expected.yaml b/conformance/tests/l1-rendering/default-deny-baseline-render/golden/expected.yaml new file mode 100644 index 0000000..517e7c3 --- /dev/null +++ b/conformance/tests/l1-rendering/default-deny-baseline-render/golden/expected.yaml @@ -0,0 +1,103 @@ +--- +# Namespace (trust zone; default-deny + policies apply) +apiVersion: v1 +kind: Namespace +metadata: + name: baseline-demo + labels: + homelab.keithjackson.dev/app-namespace: 'true' +--- +# Default-deny baseline (R-NET-01): deny all, allow only DNS +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: baseline-demo + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: baseline-demo + namespace: baseline-demo + labels: + app.kubernetes.io/name: baseline-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: baseline-demo + template: + metadata: + labels: + app.kubernetes.io/name: baseline-demo + homelab.keithjackson.dev/egress-class: internet + homelab.keithjackson.dev/auth-mode: none + spec: + containers: + - name: baseline-demo + image: nginx:1.27 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: baseline-demo + namespace: baseline-demo + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: baseline-demo +spec: + selector: + matchLabels: + app.kubernetes.io/name: baseline-demo + namespaceSelector: + matchNames: + - baseline-demo + endpoints: + - port: metrics + interval: 30s +--- +# Flux ImageRepository (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageRepository +metadata: + name: baseline-demo + namespace: baseline-demo +spec: + image: nginx + interval: 1h0m0s +--- +# Flux ImagePolicy (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImagePolicy +metadata: + name: baseline-demo + namespace: baseline-demo + annotations: + homelab.keithjackson.dev/update-channel: stable + homelab.keithjackson.dev/auto-rollback: 'true' +spec: + imageRepositoryRef: + name: baseline-demo + policy: + semver: + range: '>=0.0.0' diff --git a/conformance/tests/l1-rendering/default-deny-baseline-render/render-check.sh b/conformance/tests/l1-rendering/default-deny-baseline-render/render-check.sh new file mode 100755 index 0000000..26470e0 --- /dev/null +++ b/conformance/tests/l1-rendering/default-deny-baseline-render/render-check.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# ============================================================================= +# L1 render check (offline, no cluster) — default-deny-baseline-render +# Serves: R-NET-01 (every namespace renders a default-deny baseline). Run by +# conformance/run.sh --layer l1 and ci/l1.sh. Pure bash + catalog/render.py. +# +# GIVEN any onboarded service (this fixture declares NO dependencies), +# WHEN its resources are rendered, +# THEN (1) the render matches the committed golden byte-for-byte, and +# (2) it includes a NetworkPolicy labelled baseline=default-deny that denies +# all ingress (ingress: []) and covers BOTH Ingress and Egress — the +# render-time partner of the Kyverno L0 gate and the L2 behavioral test. +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root +RENDER="${ROOT}/catalog/render.py" +PYTHON="${PYTHON:-python3}" +LBL="homelab.keithjackson.dev" + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +fail() { echo "RENDER-CHECK FAIL (default-deny-baseline-render): $*" >&2; exit 1; } + +"${PYTHON}" "${RENDER}" "${DIR}/fixtures/svcdef.yaml" > "${tmp}/out.yaml" + +# (1) golden match. +diff -u "${DIR}/golden/expected.yaml" "${tmp}/out.yaml" || fail "render drifted from golden" + +# (2) the baseline is present and shaped as deny-all + both directions. +grep -q "${LBL}/baseline: default-deny" "${tmp}/out.yaml" \ + || fail "render is missing the default-deny baseline NetworkPolicy (R-NET-01)" + +# Structural assertion via the renderer's own parse (deterministic, no cluster): +# find the default-deny NetworkPolicy and assert its shape. +"${PYTHON}" - "${tmp}/out.yaml" <<'PY' || fail "default-deny baseline is malformed (R-NET-01)" +import sys, yaml +docs = [d for d in yaml.safe_load_all(open(sys.argv[1])) if d] +np = [d for d in docs + if d.get("kind") == "NetworkPolicy" + and d.get("metadata", {}).get("labels", {}).get( + "homelab.keithjackson.dev/baseline") == "default-deny"] +assert len(np) == 1, f"expected exactly one default-deny baseline, got {len(np)}" +spec = np[0]["spec"] +assert spec.get("ingress") == [], "baseline must deny ALL ingress (ingress: [])" +assert set(spec.get("policyTypes", [])) == {"Ingress", "Egress"}, \ + "baseline must cover BOTH Ingress and Egress" +PY + +echo "render-check OK: default-deny-baseline-render (R-NET-01)" diff --git a/conformance/tests/l1-rendering/egress-class-render/chainsaw-test.yaml b/conformance/tests/l1-rendering/egress-class-render/chainsaw-test.yaml index 8604a37..6ad2275 100644 --- a/conformance/tests/l1-rendering/egress-class-render/chainsaw-test.yaml +++ b/conformance/tests/l1-rendering/egress-class-render/chainsaw-test.yaml @@ -13,9 +13,12 @@ # (and `egress: internet` does NOT), and flipping the class only changes the # egress path (R-NET-07), not the workload spec. # -# STATUS: scaffold — needs T-0.3 (renderer contract) + T-3.1/T-3.2 (egress design). -# Rendering assertions can run without live behavior, but Chainsaw applies to a -# cluster; until Phase 1 this is skipped by run.sh like every other layer. +# STATUS: IMPLEMENTED (T-0.3). The render assertion is a pure offline check +# (render-check.sh: renderer + golden diff, no cluster). conformance/run.sh --layer l1 +# and ci/l1.sh run render-check.sh directly WITHOUT a cluster (docs/14: "L1 rendering +# assertions run in CI without a cluster"). This Chainsaw Test wraps the SAME +# render-check.sh in a script step so an in-cluster Chainsaw run executes the identical +# assertion — rendering the policy, NOT enforcing it (enforcement is proven at L2/L3). # ============================================================================= apiVersion: chainsaw.kyverno.io/v1alpha1 kind: Test @@ -33,19 +36,11 @@ metadata: egress: vpn renders the egress-gateway + kill-switch path; internet does not. spec: steps: - - name: given-vpn-servicedefinition-render + - name: render-and-assert-egress-class + description: >- + Render the vpn + internet fixtures and assert: each matches its golden; the vpn + render carries the egress-class=vpn steering label and internet does not; and the + two renders differ ONLY in the egress-class value (R-NET-07). try: - script: - # TODO(T-0.3): invoke the renderer on a fixture ServiceDefinition - # (egress: vpn) and capture rendered YAML to $RENDER_OUT. - content: 'echo "TODO: render fixtures/svcdef-egress-vpn.yaml -> rendered.yaml"' - - name: then-render-contains-killswitched-egress - try: - - assert: - # TODO(T-3.x): assert the rendered set includes the egress-gateway - # steering + kill-switch construct for a vpn-class workload. - file: golden/egress-vpn.expected.yaml # TODO: author golden file - - name: then-internet-render-omits-vpn-path - try: - - script: - content: 'echo "TODO: render egress: internet fixture; assert NO vpn/gateway steer"' + content: bash ./render-check.sh diff --git a/conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-internet.yaml b/conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-internet.yaml new file mode 100644 index 0000000..93435f6 --- /dev/null +++ b/conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-internet.yaml @@ -0,0 +1,30 @@ +# L1 fixture: an internet-class ServiceDefinition. BYTE-IDENTICAL to svcdef-egress-vpn.yaml +# EXCEPT `egress: internet`. The two renders must differ ONLY in the egress path (R-NET-07): +# the internet render carries NO vpn steering label. Validates against the schema. +name: sonarr +kind: container +source: + image: linuxserver/sonarr +exposure: internal +egress: internet +storage: + - name: config + size: 2Gi + class: longhorn +ingressRoutes: + - host: sonarr.home.keithjackson.dev + port: 8989 +auth: + mode: forward-auth + groups: + - media-admins +update: + channel: stable + cadence: "0 4 * * *" + autoRollbackOnFailedHealth: true +observability: + metrics: true + slis: + - up +backup: + policy: daily-offcluster diff --git a/conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-vpn.yaml b/conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-vpn.yaml new file mode 100644 index 0000000..51e2ab6 --- /dev/null +++ b/conformance/tests/l1-rendering/egress-class-render/fixtures/svcdef-egress-vpn.yaml @@ -0,0 +1,31 @@ +# L1 fixture: a vpn-class ServiceDefinition. Identical to svcdef-egress-internet.yaml +# EXCEPT `egress: vpn`, so the render diff isolates the egress path (R-NET-05/07). +# sonarr is in the normative vpn-class set (docs/03/13). Validates against +# schemas/servicedefinition.schema.json. +name: sonarr +kind: container +source: + image: linuxserver/sonarr +exposure: internal +egress: vpn +storage: + - name: config + size: 2Gi + class: longhorn +ingressRoutes: + - host: sonarr.home.keithjackson.dev + port: 8989 +auth: + mode: forward-auth + groups: + - media-admins +update: + channel: stable + cadence: "0 4 * * *" + autoRollbackOnFailedHealth: true +observability: + metrics: true + slis: + - up +backup: + policy: daily-offcluster diff --git a/conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml b/conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml new file mode 100644 index 0000000..efec1b0 --- /dev/null +++ b/conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml @@ -0,0 +1,200 @@ +--- +# Namespace (trust zone; default-deny + policies apply) +apiVersion: v1 +kind: Namespace +metadata: + name: sonarr + labels: + homelab.keithjackson.dev/app-namespace: 'true' + gateway-access: shared + annotations: + homelab.keithjackson.dev/backup-policy: daily-offcluster +--- +# Default-deny baseline (R-NET-01): deny all, allow only DNS +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: sonarr + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# PVC sonarr-config (R-STATE) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sonarr-config + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 2Gi +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sonarr + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: sonarr + template: + metadata: + labels: + app.kubernetes.io/name: sonarr + homelab.keithjackson.dev/egress-class: internet + homelab.keithjackson.dev/auth-mode: forward-auth + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: media-admins + spec: + containers: + - name: sonarr + image: linuxserver/sonarr + ports: + - containerPort: 8989 + volumeMounts: + - name: config + mountPath: /data/config + volumes: + - name: config + persistentVolumeClaim: + claimName: sonarr-config +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: sonarr + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + selector: + app.kubernetes.io/name: sonarr + ports: + - name: port-8989 + port: 8989 + targetPort: 8989 +--- +# HTTPRoute sonarr-8989 (R-TLS/R-DNS) +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: sonarr-8989 + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: media-admins +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - sonarr.home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: sonarr + port: 8989 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: sonarr + namespace: sonarr + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: sonarr +spec: + selector: + matchLabels: + app.kubernetes.io/name: sonarr + namespaceSelector: + matchNames: + - sonarr + endpoints: + - port: metrics + interval: 30s +--- +# PrometheusRule SLI alerts (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: sonarr-slis + namespace: sonarr + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: sonarr +spec: + groups: + - name: sonarr.slis + rules: + - alert: sonarr-down + expr: up{job="sonarr"} == 0 + for: 5m + labels: + severity: critical + service: sonarr + annotations: + summary: sonarr is down + description: 'sonarr has been unreachable for 5m (SLI: up).' +--- +# Flux ImageRepository (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageRepository +metadata: + name: sonarr + namespace: sonarr +spec: + image: linuxserver/sonarr + interval: 1h0m0s +--- +# Flux ImagePolicy (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImagePolicy +metadata: + name: sonarr + namespace: sonarr + annotations: + homelab.keithjackson.dev/update-channel: stable + homelab.keithjackson.dev/auto-rollback: 'true' +spec: + imageRepositoryRef: + name: sonarr + policy: + semver: + range: '>=0.0.0' diff --git a/conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml b/conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml new file mode 100644 index 0000000..4caaa7f --- /dev/null +++ b/conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml @@ -0,0 +1,200 @@ +--- +# Namespace (trust zone; default-deny + policies apply) +apiVersion: v1 +kind: Namespace +metadata: + name: sonarr + labels: + homelab.keithjackson.dev/app-namespace: 'true' + gateway-access: shared + annotations: + homelab.keithjackson.dev/backup-policy: daily-offcluster +--- +# Default-deny baseline (R-NET-01): deny all, allow only DNS +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: sonarr + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# PVC sonarr-config (R-STATE) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sonarr-config + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 2Gi +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sonarr + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: sonarr + template: + metadata: + labels: + app.kubernetes.io/name: sonarr + homelab.keithjackson.dev/egress-class: vpn + homelab.keithjackson.dev/auth-mode: forward-auth + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: media-admins + spec: + containers: + - name: sonarr + image: linuxserver/sonarr + ports: + - containerPort: 8989 + volumeMounts: + - name: config + mountPath: /data/config + volumes: + - name: config + persistentVolumeClaim: + claimName: sonarr-config +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: sonarr + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + selector: + app.kubernetes.io/name: sonarr + ports: + - name: port-8989 + port: 8989 + targetPort: 8989 +--- +# HTTPRoute sonarr-8989 (R-TLS/R-DNS) +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: sonarr-8989 + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: media-admins +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - sonarr.home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: sonarr + port: 8989 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: sonarr + namespace: sonarr + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: sonarr +spec: + selector: + matchLabels: + app.kubernetes.io/name: sonarr + namespaceSelector: + matchNames: + - sonarr + endpoints: + - port: metrics + interval: 30s +--- +# PrometheusRule SLI alerts (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: sonarr-slis + namespace: sonarr + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: sonarr +spec: + groups: + - name: sonarr.slis + rules: + - alert: sonarr-down + expr: up{job="sonarr"} == 0 + for: 5m + labels: + severity: critical + service: sonarr + annotations: + summary: sonarr is down + description: 'sonarr has been unreachable for 5m (SLI: up).' +--- +# Flux ImageRepository (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageRepository +metadata: + name: sonarr + namespace: sonarr +spec: + image: linuxserver/sonarr + interval: 1h0m0s +--- +# Flux ImagePolicy (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImagePolicy +metadata: + name: sonarr + namespace: sonarr + annotations: + homelab.keithjackson.dev/update-channel: stable + homelab.keithjackson.dev/auto-rollback: 'true' +spec: + imageRepositoryRef: + name: sonarr + policy: + semver: + range: '>=0.0.0' diff --git a/conformance/tests/l1-rendering/egress-class-render/render-check.sh b/conformance/tests/l1-rendering/egress-class-render/render-check.sh new file mode 100755 index 0000000..1b7a4be --- /dev/null +++ b/conformance/tests/l1-rendering/egress-class-render/render-check.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# ============================================================================= +# L1 render check (offline, no cluster) — egress-class-render +# Serves: R-NET-05 (egress is a declared property), R-NET-06/07 (vpn steering / +# flip only changes the egress path). Run by conformance/run.sh --layer l1 and by +# ci/l1.sh. Pure bash + the renderer (catalog/render.py); no cluster, no chainsaw. +# +# GIVEN two ServiceDefinitions identical except `egress: vpn` vs `egress: internet`, +# WHEN the renderer produces resources, +# THEN (1) each matches its committed golden byte-for-byte, +# (2) the vpn render carries the egress-class=vpn steering label and the +# internet render does NOT (fail-closed: no silent vpn path on internet), +# (3) the two renders differ ONLY in the egress-class value (R-NET-07): +# flipping the class changes the egress path, not the workload spec. +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root +RENDER="${ROOT}/catalog/render.py" +PYTHON="${PYTHON:-python3}" +LBL="homelab.keithjackson.dev" + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +fail() { echo "RENDER-CHECK FAIL (egress-class-render): $*" >&2; exit 1; } + +# (1) golden match for both classes. +"${PYTHON}" "${RENDER}" "${DIR}/fixtures/svcdef-egress-vpn.yaml" > "${tmp}/vpn.yaml" +"${PYTHON}" "${RENDER}" "${DIR}/fixtures/svcdef-egress-internet.yaml" > "${tmp}/internet.yaml" +diff -u "${DIR}/golden/egress-vpn.expected.yaml" "${tmp}/vpn.yaml" || fail "vpn render drifted from golden" +diff -u "${DIR}/golden/egress-internet.expected.yaml" "${tmp}/internet.yaml" || fail "internet render drifted from golden" + +# (2) vpn steering label present on vpn, absent on internet (R-NET-05, fail-closed). +grep -q "${LBL}/egress-class: vpn" "${tmp}/vpn.yaml" \ + || fail "vpn render is missing the egress-class=vpn steering label" +if grep -q "${LBL}/egress-class: vpn" "${tmp}/internet.yaml"; then + fail "internet render must NOT carry the vpn steering label (fail-open leak)" +fi +grep -q "${LBL}/egress-class: internet" "${tmp}/internet.yaml" \ + || fail "internet render is missing the egress-class=internet label" + +# (3) R-NET-07: the only difference is the egress-class value. Neutralize the class +# value in each render; the rest MUST be byte-identical. +sed 's#egress-class: vpn#egress-class: CLASS#' "${tmp}/vpn.yaml" > "${tmp}/vpn.norm" +sed 's#egress-class: internet#egress-class: CLASS#' "${tmp}/internet.yaml" > "${tmp}/internet.norm" +diff -u "${tmp}/vpn.norm" "${tmp}/internet.norm" \ + || fail "flipping egress class changed more than the egress path (R-NET-07 violated)" + +echo "render-check OK: egress-class-render (R-NET-05/06/07)" From a79f24f75e47fa0734043f6425b364c891a14604 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 24 Aug 2026 22:22:04 -0500 Subject: [PATCH 3/3] Add container-workload-render L1 test (R-PLAT-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer's PaaS path (kind: container) had no verifying conformance test — the R-PLAT-02 token appears only in renderer comments, so a change touching catalog/ surfaced it as an uncovered touched-requirement (blocking the merge gate). Adds the container render analog of vm-lifecycle (R-PLAT-03): asserts a kind: container ServiceDefinition renders a Deployment (running the declared image) + a Service, with the egress-class/auth-mode labels admission requires. Offline render-check.sh, wrapped by a chainsaw Test for in-cluster runs. Co-Authored-By: Claude Opus 4.8 --- .../chainsaw-test.yaml | 36 +++++ .../fixtures/svcdef.yaml | 14 ++ .../golden/expected.yaml | 152 ++++++++++++++++++ .../container-workload-render/render-check.sh | 52 ++++++ 4 files changed, 254 insertions(+) create mode 100644 conformance/tests/l1-rendering/container-workload-render/chainsaw-test.yaml create mode 100644 conformance/tests/l1-rendering/container-workload-render/fixtures/svcdef.yaml create mode 100644 conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml create mode 100755 conformance/tests/l1-rendering/container-workload-render/render-check.sh diff --git a/conformance/tests/l1-rendering/container-workload-render/chainsaw-test.yaml b/conformance/tests/l1-rendering/container-workload-render/chainsaw-test.yaml new file mode 100644 index 0000000..05418e5 --- /dev/null +++ b/conformance/tests/l1-rendering/container-workload-render/chainsaw-test.yaml @@ -0,0 +1,36 @@ +# ============================================================================= +# L1 RENDERING — kind: container renders the PaaS workload (R-PLAT-02) +# Catalog: docs/13 `kind: container` (PaaS default). The render analog of the +# vm-lifecycle (R-PLAT-03) test for the container path. +# +# GIVEN a kind: container ServiceDefinition, +# WHEN the renderer produces resources, +# THEN it renders a Deployment (the container workload) + a Service, matching the +# golden, with the egress-class + auth-mode labels admission requires. +# +# STATUS: IMPLEMENTED (T-0.3). render-check.sh is a pure offline check (renderer + +# golden diff + structural asserts, no cluster). conformance/run.sh --layer l1 and +# ci/l1.sh run it directly; this Chainsaw Test wraps the SAME script so an in-cluster +# run executes the identical assertion. Rendering != enforcement (behavior at L2). +# ============================================================================= +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: container-workload-render + labels: + conformance.home.k3s/R-PLAT-02: "true" + annotations: + conformance.home.k3s/requirements: "R-PLAT-02" + conformance.home.k3s/layer: "L1" + conformance.home.k3s/safety-critical: "false" + conformance.home.k3s/intent: >- + A kind: container ServiceDefinition renders a Deployment + Service (the PaaS path). +spec: + steps: + - name: render-and-assert-container-workload + description: >- + Render a container ServiceDefinition and assert it produces a Deployment running + the declared image plus a Service, with the required pod labels, matching golden. + try: + - script: + content: bash ./render-check.sh diff --git a/conformance/tests/l1-rendering/container-workload-render/fixtures/svcdef.yaml b/conformance/tests/l1-rendering/container-workload-render/fixtures/svcdef.yaml new file mode 100644 index 0000000..261eef4 --- /dev/null +++ b/conformance/tests/l1-rendering/container-workload-render/fixtures/svcdef.yaml @@ -0,0 +1,14 @@ +# L1 fixture: a minimal kind: container ServiceDefinition — the PaaS path (R-PLAT-02). +# It must render a Deployment (the container workload) + a Service for its exposed port. +# Validates against schemas/servicedefinition.schema.json. +name: paas-demo +kind: container +source: + image: nginx:1.27 +ingressRoutes: + - host: paas-demo.home.keithjackson.dev + port: 8080 +auth: + mode: forward-auth + groups: + - platform-admins diff --git a/conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml b/conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml new file mode 100644 index 0000000..96f9d06 --- /dev/null +++ b/conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml @@ -0,0 +1,152 @@ +--- +# Namespace (trust zone; default-deny + policies apply) +apiVersion: v1 +kind: Namespace +metadata: + name: paas-demo + labels: + homelab.keithjackson.dev/app-namespace: 'true' + gateway-access: shared +--- +# Default-deny baseline (R-NET-01): deny all, allow only DNS +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: paas-demo + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: paas-demo + namespace: paas-demo + labels: + app.kubernetes.io/name: paas-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: paas-demo + template: + metadata: + labels: + app.kubernetes.io/name: paas-demo + homelab.keithjackson.dev/egress-class: internet + homelab.keithjackson.dev/auth-mode: forward-auth + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: platform-admins + spec: + containers: + - name: paas-demo + image: nginx:1.27 + ports: + - containerPort: 8080 +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: paas-demo + namespace: paas-demo + labels: + app.kubernetes.io/name: paas-demo +spec: + selector: + app.kubernetes.io/name: paas-demo + ports: + - name: port-8080 + port: 8080 + targetPort: 8080 +--- +# HTTPRoute paas-demo-8080 (R-TLS/R-DNS) +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: paas-demo-8080 + namespace: paas-demo + labels: + app.kubernetes.io/name: paas-demo + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: platform-admins +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - paas-demo.home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: paas-demo + port: 8080 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: paas-demo + namespace: paas-demo + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: paas-demo +spec: + selector: + matchLabels: + app.kubernetes.io/name: paas-demo + namespaceSelector: + matchNames: + - paas-demo + endpoints: + - port: metrics + interval: 30s +--- +# Flux ImageRepository (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageRepository +metadata: + name: paas-demo + namespace: paas-demo +spec: + image: nginx + interval: 1h0m0s +--- +# Flux ImagePolicy (R-UPD) +apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImagePolicy +metadata: + name: paas-demo + namespace: paas-demo + annotations: + homelab.keithjackson.dev/update-channel: stable + homelab.keithjackson.dev/auto-rollback: 'true' +spec: + imageRepositoryRef: + name: paas-demo + policy: + semver: + range: '>=0.0.0' diff --git a/conformance/tests/l1-rendering/container-workload-render/render-check.sh b/conformance/tests/l1-rendering/container-workload-render/render-check.sh new file mode 100755 index 0000000..bbe4cd4 --- /dev/null +++ b/conformance/tests/l1-rendering/container-workload-render/render-check.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# ============================================================================= +# L1 render check (offline, no cluster) — container-workload-render +# Serves: R-PLAT-02 (PaaS: a kind: container ServiceDefinition renders a container +# workload). The render analog of vm-lifecycle (R-PLAT-03) for the container path. +# Run by conformance/run.sh --layer l1 and ci/l1.sh. Pure bash + catalog/render.py. +# +# GIVEN a kind: container ServiceDefinition with an exposed port, +# WHEN the renderer produces resources, +# THEN (1) the render matches the committed golden byte-for-byte, and +# (2) it includes a Deployment running the SD's image plus a Service for the +# exposed port, and the pod carries the egress-class + auth-mode labels the +# platform's Kyverno policies require (a rendered container that admission +# would accept). +# ============================================================================= +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root +RENDER="${ROOT}/catalog/render.py" +PYTHON="${PYTHON:-python3}" + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +fail() { echo "RENDER-CHECK FAIL (container-workload-render): $*" >&2; exit 1; } + +"${PYTHON}" "${RENDER}" "${DIR}/fixtures/svcdef.yaml" > "${tmp}/out.yaml" + +# (1) golden match. +diff -u "${DIR}/golden/expected.yaml" "${tmp}/out.yaml" || fail "render drifted from golden" + +# (2) structural: a Deployment (image) + Service (port) + the required pod labels. +"${PYTHON}" - "${tmp}/out.yaml" <<'PY' || fail "container render is missing the PaaS workload shape (R-PLAT-02)" +import sys, yaml +docs = [d for d in yaml.safe_load_all(open(sys.argv[1])) if d] +by = {} +for d in docs: + by.setdefault(d.get("kind"), []).append(d) +assert by.get("Deployment"), "expected a Deployment (the container workload)" +dep = by["Deployment"][0] +c = dep["spec"]["template"]["spec"]["containers"][0] +assert c["image"] == "nginx:1.27", f"unexpected image {c['image']!r}" +labels = dep["spec"]["template"]["metadata"]["labels"] +L = "homelab.keithjackson.dev" +assert labels.get(f"{L}/egress-class") in ("internet", "vpn"), "pod missing egress-class label" +assert labels.get(f"{L}/auth-mode"), "pod missing auth-mode label" +assert by.get("Service"), "expected a Service for the exposed port" +ports = [p["port"] for p in by["Service"][0]["spec"]["ports"]] +assert 8080 in ports, f"Service does not expose port 8080 ({ports})" +PY + +echo "render-check OK: container-workload-render (R-PLAT-02)"