Merge feature/servicedefinition-renderer: ServiceDefinition renderer T-0.3 (R-GITOPS-02) + L1 render tests green
This commit is contained in:
18 files changed
+1905
-26
No files matched your search
@@ -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/<name>.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 <rendered-dir>` 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).
|
||||
@@ -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 <sd>` (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 <name>.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())
|
||||
@@ -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 <out.xml> [--label-prefix P] <test-dir> [<test-dir> ...]
|
||||
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())
|
||||
+44
-2
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
+51
@@ -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)"
|
||||
@@ -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
|
||||
+30
@@ -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
|
||||
@@ -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
|
||||
+200
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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)"
|
||||
Reference in new issue
Block a user