Files
home-network-k3s-impl/schemas/renderer-contract.md
T
jakman85andClaude Opus 5.5 8fe8d5df8a
gate / gate (pull_request) Successful in 1m14s
gate / conformance (pull_request) Skipped
feat(dns): PiHole answers an explicit LAN name list, not a zone wildcard
Public names are proxied CNAMEs to the Cloudflare Tunnel since PR #156 and
work from the LAN, so they should resolve publicly there too. Replace the
address=/${BASE_DOMAIN}/ wildcard with explicit entries: the LAN-only names
(derived from every controller:none HTTPRoute), gitea/nextcloud (off
Cloudflare's 100 MB / 100 s limits), keycloak/auth, and the L4 VIPs.

Add L0 check pihole-lan-names, mutation-tested. Correct docs that still
described public names as CNAME to wan. and the cloudflared entrypoint comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:51:49 -06:00

38 KiB
Raw Blame History

Renderer Contract — ServiceDefinition → rendered resources

Status: authoritative Phase-0 interface contract. Consumed by the manifest renderer (WS-GITOPS), the network/egress renderer (WS-NET), storage (WS-STATE), TLS/DNS (WS-EDGE), identity (WS-SEC), observability (WS-OBS), updates (WS-UPD), and the conformance/policy/CI agents (WS-TEST).

Source of truth: spec docs/13-service-catalog.md (the ServiceDefinition schema and default posture). This document maps each field of servicedefinition.schema.json to the concrete resources the platform must render, and cites the requirement each mapping satisfies. Changing this contract is an interface change — coordinate per the spec's agents/coordination-protocol.md (§2).

The renderer MUST first materialize the schema defaults (below), then apply this mapping. A field absent from a ServiceDefinition renders from its default posture, not "not at all."

Default posture (applied before mapping) — docs/13 §"Default posture"

Field Default Requirement
kind container R-PLAT-02/03
exposure internal R-DNS/R-NET
egress internet R-NET-05
storage [] (config PVC still expected for stateful svc) R-STATE-02
dependencies [] → reaches only DNS (default-deny) R-NET-01
inClusterClients [] → no direct east-west client; only the Gateway may reach an exposed port R-NET-01/02
auth.mode forward-auth for any exposed UI R-IDP-02
auth.apiRoutes [] → the sidecar renders no --api-route; every unauthenticated request is 302'd to Keycloak R-IDP-02
update.channel stable R-UPD-01
update.autoRollbackOnFailedHealth true R-UPD-03
observability.metrics true R-OBS-01
observability.metricsEndpoint absent → the workload serves no Prometheus endpoint; no ServiceMonitor R-OBS-01
observability.livenessWorkload the object this SD renders (Deployment named after the service; VirtualMachineInstance for kind: vm) R-OBS-01
backup of irreplaceable state off-cluster (never reacquirable) R-STATE-03

Field → resource mapping

name

  • Renders: the service Namespace (trust zone), Deployment/VM name, Service name, and the DNS label <name>.home.keithjackson.dev.
  • Requirements: R-DNS-01 (naming model, docs/09), R-NET (namespace = isolation boundary, docs/03).

kind + source

  • container + source.image → Deployment (+ Service) using the image ref.
  • vm + source.vmTemplate → KubeVirt VirtualMachine with a CDI-imported root disk.
  • The schema enforces exactly-one-of (image for container, vmTemplate for vm).
  • Requirements: R-PLAT-02/03 (PaaS/IaaS, docs/13); VM disks inherit Longhorn durability (docs/04).

exposure (internal | public)

  • Both classes render an HTTPRoute on the shared Gateway and are reachable on the LAN via internal split-horizon DNS.

  • public additionally signals the documented manual edge port-forward of 80/443 (and any service-specific port, e.g. Plex 32400, Minecraft 25565) to the ingress VIP, and a public ExternalDNS record for <name>.home.keithjackson.dev.

  • internal renders no public DNS record and is never port-forwarded (PiHole resolves it to the internal Gateway VIP).

  • HOW "no public DNS record" IS MADE TRUE, since it was prose only until 2026-09-14. Every HTTPRoute rendered for an internal service carries ExternalDNS's own controller annotation with a value other than the one ExternalDNS claims:

    external-dns.alpha.kubernetes.io/controller: none
    

    ExternalDNS v0.21.0 (source/annotations/processors.go) skips any source object whose controller annotation is present and != "dns-controller", and source/gateway.go applies that check inside Endpoints() before any hostname is resolved — so the route never becomes an endpoint for any name it declares. source/service.go applies the identical check, which covers the second configured source. It is derived from exposure and nothing else (catalog/render.py _externaldns_route_annotations), so the operator's one declaration decides, a service that OMITS exposure inherits the internal default and is likewise unpublishable, and there is no per-service list to forget to extend. ci/render-golden.sh proves apps/ reproduces from catalog/, so an internal service cannot exist in the applied tree without it, and conformance/tests/l0-static/internal-services-publish-no-public-dns re-proves both directions (present on internal, absent on public) on every gate run.

    • The route itself, the wildcard TLS and LAN reachability are unchanged — only the public record is withdrawn. The internal split-horizon answer is Pi-hole's job (R-DNS-03), which is why withdrawing these records requires Pi-hole to be answering the LAN authoritatively first: the zone has no *.home.keithjackson.dev wildcard, and *.keithjackson.dev does not match two labels, so a withdrawn name is a public NXDOMAIN with no fallback.
    • Why not a cluster-wide ExternalDNS opt-in filter (labelFilter, which v0.21.0 supports for both configured sources): seven published names come from hand-authored platform//apps/ manifests that carry no exposure field for a renderer to read, and an opt-in filter deletes all seven the moment it is set. Per-resource exclusion has exactly the blast radius of the defect. The filter stays available as hardening once those seven are adjudicated — see platform/externaldns/helmrelease.yaml and the register in the L0 check.
  • public renders the record but the renderer does not set a target: external-dns reads external-dns.alpha.kubernetes.io/target only from the Gateway (and a ListenerSet), never from an HTTPRoute, so the target is set once, on the shared Gateway — since 2026-11-08 the Cloudflare Tunnel, proxied (platform/gateway/gateway.yaml, platform/externaldns/README.md).

  • Exposure does not relax east-west default-deny (docs/09 §"Interaction with default-deny").

  • Requirements: R-DNS-02/03 (docs/09), R-NET-04 (docs/03).

egress (internet | vpn) — egress steering

  • internet → pod egress allow to the default WAN route (node → SOHO edge → internet).
  • vpn → the pod is steered through the shared in-cluster Gluetun/PIA (WireGuard) egress gateway (Cilium Egress Gateway / routing). The pod gets no independent egress path; the kill-switch (fail-closed) and allow rules are enforced at the gateway + namespace/policy level, outside the workload.
  • Requirements: R-NET-05 (egress class), R-NET-06 (VPN kill-switch, mandatory & central). VPN-class set is normative: sonarr, radarr, jackett, qbittorrent (docs/03/13).

dependencies[] (to / port / protocol) — east-west policy

  • Each namespace first gets the default-deny baseline (deny all ingress + egress) applied by GitOps at namespace creation, with a standing allow only for DNS to the in-cluster resolver. Observability scraping is NOT in the baseline — it is an additive allow rendered per declared observability.metricsEndpoint (see below), scoped to that one port. This paragraph used to claim the baseline carried a standing monitoring allow; it never did, and an app metrics port would have been dropped by policy even with the Service port and monitor endpoint in agreement.
  • Each dependencies[] entry renders one CiliumNetworkPolicy egress allow: this service → to service on port/protocol. Everything undeclared stays denied.
  • Empty dependencies ⇒ the service can reach only DNS.
  • Requirements: R-NET-01 (default-deny baseline), R-NET-02 (explicit allow / deny undeclared). Auditable via Hubble (R-SEC-03, R-OBS-04).

storage[] (name / size / class / mountPath / export / subPath / readOnly) — PVs + PVCs

  • class: longhorn → a PVC on the 3-replica Longhorn StorageClass sized by size (replicated config/DB/app state), ReadWriteOnce, dynamically provisioned. For a vm, the disk is a Longhorn volume via CDI.
  • class: bulk → a static, pre-bound external NFS PersistentVolume and its PVC. The entry's export is the absolute path on the bulk server; the SERVER is never named in a ServiceDefinition — it is the ${BULK_NFS_SERVER} cluster-vars knob, so one authored definition promotes across environments that reach the same storage by different addresses (R-ENV-02). Not 3× replicated: durability is the storage host's ZFS pool, not Longhorn.
    • storageClassName: "" on both PV and PVC (explicitly empty ⇒ statically provisioned; no provisioner is ever asked to create anything, and the default-StorageClass annotation is suppressed). Deliberately NOT the bulk StorageClass: a StorageClass provisioner is immutable, so re-pointing that stub would make Flux fail permanently on it.
    • Binding is pinned from both ends — claimRef on the PV, volumeName on the PVC — so no other claim of matching size/mode can win a race for the volume.
    • persistentVolumeReclaimPolicy: Retain, always. Bulk data pre-exists the cluster, is far larger than the cluster, and may have consumers outside it; nothing here may reclaim it.
    • Access mode follows the read/write posture and is always …Many, because one export is consumed by several namespaces and several nodes at once: ReadOnlyMany when readOnly, ReadWriteMany otherwise. ReadWriteOnce cannot express either.
    • mountOptions are a platform decision held in the renderer, not a per-service field: nfsvers=4.1,hard,noatime,timeo=600,retrans=2. NFSv4 carries mount and lock in-protocol, so the only port that must be reachable is tcp/2049 — no rpcbind/111, mountd or statd, which is what keeps the firewall surface to a single rule. hard is chosen over soft because a soft mount converts a server hiccup into a short read handed to the application.
    • size on a bulk entry is the PV's advertised capacity and nothing more — no component enforces it (the ZFS pool is the real limit), so it should state the dataset's actual size so kubectl get pv reports something true.
    • fsGroup does not apply. The in-tree NFS volume plugin performs no ownership management, so podSecurityContext.fsGroup is a no-op on a bulk volume. A bulk mount that must be written therefore requires the container's own uid/gid to be one the export's ownership permits (e.g. Plex's PLEX_UID/PLEX_GID, LinuxServer images' PUID/PGID) — or a server-side squash. A mount that succeeds but cannot write is the characteristic failure of this tier.
  • mountPath (any class) overrides the /data/<name> default verbatim.
  • subPath (any class) mounts a subtree instead of the volume root. Two subPath mounts of one export are two separate mounts, so link() between them fails EXDEV; a workload that needs hardlinks or atomic moves between two directories (Sonarr/Radarr importing from a download path into a library) must mount their common ancestor once instead.
  • readOnly (any class) marks the volumeMount read-only, and on a bulk entry also sets the PV's nfs.readOnly so the kernel mount really is ro. Note this is a client-side guarantee: one export serving both a reader and a writer that may land on any node cannot distinguish them server-side.
  • State separation rule: anything that must survive a rebuild lives on a named volume here, never on ephemeral fs/hostPath — so the workload can be destroyed and re-applied from git with data intact.
  • Requirements: R-STATE-01 (rebuild survival), R-STATE-02 (Longhorn replication), R-STATE-04 (bulk tier). (docs/04)

availability (replicas) — workload kind, per-replica volumes, spreading, budget

Schema 1.10.0. Availability INTENT. It names no Kubernetes object, no topology key and no budget — exactly like hardware.gpu, and for the same reason: the mechanism below should be replaceable without a catalog edit.

  • Absent, or replicas: 1 → a Deployment, standalone PVC(s), strategy: Recreate when there is storage, no PodDisruptionBudget. Byte-identical to pre-1.10.0 output, so every existing app is untouched.
  • replicas: N > 1 → all of the following, together:
    Rendered Why
    StatefulSet instead of Deployment A longhorn PVC is ReadWriteOnce. One RWO claim cannot attach on two nodes, so a multi-replica Deployment does not degrade — the second pod never schedules.
    longhorn storage becomes volumeClaimTemplates Gives each replica its own claim (<vol>-<svc>-<ordinal>, e.g. config-pihole-0). The template's metadata.name must equal the pod volume name the container mounts. The standalone PVC is then not emitted — it would be an orphan nothing mounts.
    bulk storage stays a standalone PVC A bulk entry is a static, pre-bound NFS PV over data that already exists outside the cluster and is shared read-only. Per-replica copies are neither possible nor wanted.
    required podAntiAffinity on kubernetes.io/hostname preferred would let every replica land on one node and deliver none of the intended node-failure tolerance, while every object still reported healthy.
    podManagementPolicy: Parallel OrderedReady serialises first-boot work across replicas; apps-wave-0 has timeout: 5m. There is no ordering requirement between replicas to protect.
    PodDisruptionBudget, minAvailable: 1 Stops a voluntary disruption (node drain, upgrade) taking the last replica. It does nothing about a node dying — that is what the replica count and anti-affinity are for. Never rendered at replicas: 1, where minAvailable: 1 would block every drain of that node forever.
    observability.livenessWorkload.kind defaults to StatefulSet The up SLI reads kube-state-metrics by workload kind. A Deployment-shaped rule against a StatefulSet absent()s forever — an alert that can never fire. Deriving the default means bumping the replica count cannot leave a stale rule behind.

Two costs, both deliberate, neither hidden:

  1. N replicas need N schedulable nodes. On a 3-node cluster, losing two nodes leaves a replica Pending; since apps-wave-0 is wait: true and apps-wave-1 dependsOn it, that stalls the app tier. That coincides with having lost etcd quorum on a 3-node control plane, so nothing is reconciling at that point anyway — the stall is a symptom, not a new failure mode.
  2. Only declare > 1 for a service whose on-disk state is reproducible from git. A replica with a fresh volume comes up serving whatever it can bootstrap itself and nothing else. For pihole that prerequisite was done first — the admin password and the adlists were declared in decisions/2026-10-04-pihole-declare-state.md — because otherwise a second replica would have served DNS while blocking nothing, with a different password.

Requirements: R-PLAT-02 (the workload), R-STATE-01/02 (per-replica replicated volumes), R-OBS-01 (the SLI must follow the rendered kind).

hardware (gpu) — host device capability

  • The field is INTENT, never MECHANISM. hardware.gpu is an enum of capability classes (today: intel-quicksync), not a device path, not a hostPath, not a resource request. The ServiceDefinition says what class of host device this workload needs; how the platform hands it over is catalog/render.py's business and lives nowhere else. Do not "simplify" this into a raw /dev/... path. The payoff is no longer hypothetical: on 2026-10-01 the mechanism was swapped outright — from a /dev/dri hostPath to the Intel GPU device plugin's extended resource — as a catalog/render.py change plus a re-render, with ZERO catalog edits and no re-review of per-service device wiring. catalog/plex.yaml's hardware: block came through byte-identical. Extend the enum for a genuinely different capability class (e.g. nvidia-nvenc); never to name a second route to the same one.

  • gpu: intel-quicksync → on the app container only (never a sidecar), exactly one thing:

    resources:
      limits:
        gpu.intel.com/i915: 1
    

    That is the whole mapping. No volume, no volumeMount, no securityContext, no nodeSelector, no privileged anything.

    • limits, not requests, and that is not a style choice. An extended resource must be expressed in limits; Kubernetes mirrors requests from limits automatically, and a requests-only extended resource is rejected as invalid.
    • The resource is advertised by the intel-gpu-plugin DaemonSet (platform/intel-gpu-plugin/, its own Flux Kustomization at clusters/base/intel-gpu-plugin.yaml, pinned intel/intel-gpu-plugin:0.37.0). Measured 2026-10-01: all three prod nodes report .status.allocatable["gpu.intel.com/i915"] == 1; DaemonSet 3 desired / 3 ready / 3 available.
    • This is the only app-container resources render path in the file, and it is deliberately narrow. Do not generalise it into a CPU/memory resources feature while passing through — that is a separate field with a separate justification, and render.py --selftest asserts the emitted dict exactly so the attempt fails loudly.
  • WHY THE hostPath MECHANISM WAS ABANDONED — read this before "simplifying" it back. The first implementation (merged 2026-09-30) rendered a dri hostPath volume at /dev/dri plus a read-write mount. It was measured not to work in the live Plex pod on k3s-prod-2 on 2026-10-01. The device node was visible in the container — crw-rw---- root video2 226,128 renderD128 — and could not be opened:

    attempt result
    uid 0, O_RDWR EPERM "Operation not permitted"
    uid 1000, O_RDWR EPERM "Operation not permitted"

    EPERM is the device cgroup refusing, not EACCES from file permissions — the two look identical from the outside and are not the same problem. Proven three ways: root on the host opens the same device rc=0; the node is unified cgroup2fs; and the container's runc config.json reads linux.resources.devices: [{allow: False, access: 'rwm'}] with linux.devices: None — one deny-all rule, no allow entry for major 226. A hostPath is a filesystem operation: it binds the device node in but adds no cgroup allow rule, because Kubernetes cannot infer one from a volume. Note that uid 0 failed too — root does not bypass the cgroup device gate, so "run it as root" is not a workaround. Neither is privileged: true: Kyverno restrict-host-namespaces is Enforce on app namespaces and rejects it. The device plugin is what produces the allow rule — confirmed in the v0.37.0 source, createDeviceSpecsFromDrmFiles emits DeviceSpec{…, Permissions: "rw"}, which the kubelet turns into both the device injection and the matching cgroup allow.

  • THE FAILURE MODE THIS TRADED INTO IS WORSE IN BLAST RADIUS, AND IT IS UNAVOIDABLE. Stated plainly rather than sold:

    device/resource unavailable ⇒
    Before (hostPath, DirectoryOrCreate) kubelet mounts an empty dir; Plex runs and silently software-transcodes. Invisible, but harmless to everything else.
    Now (extended resource) the pod is Unschedulable. apps-wave-0 is wait: true and apps-wave-1 dependsOn it, so this stalls the app tier.

    There is no graceful-degradation variant: an extended resource is a hard scheduling constraint, and it is also the only thing that produces the cgroup allow rule. The workload's schedulability is now coupled to the intel-gpu-plugin DaemonSet — a plugin that is broken, removed, or merely not yet reconciled takes the app tier with it. That is the price of hardware transcoding working at all.

    • Escape hatch, one field and no code change: delete the hardware: block from the ServiceDefinition (catalog/plex.yaml) and re-render. The workload returns to software transcoding and the tier unblocks. runbooks/plex-quicksync.md carries it as a first-class procedure, because it is what someone will need at 2am.
    • The old mechanism's cost is gone, and that is the one improvement: a missing device is no longer silent. There is still no metric and no alert (media apps here render no metricsEndpoint, so no ServiceMonitor exists), but "Plex is Unschedulable" is loud, whereas "Plex is quietly software-transcoding" never was.
  • podSecurityContext.supplementalGroups is deliberately NOT rendered, and adding it would be a regression rather than a hardening. This survives the mechanism swap unchanged, and it was re-measured against the plugin's behaviour. renderD128 is root:render 0660, so the obvious move is to push the host render GID in — and it does not work. Measured in the running container on prod-2 (2026-09-30, re-measured 2026-10-01): the image's own /etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner hook finds every char device under /dev/dri, stat -c '%g' each one's GID, groupadds a matching group inside the container when none exists, and usermod -a -G the app user into it — every start, nothing hardcoded. It does exactly the same thing to a plugin-injected device as to a bind-mounted one, which is why this swap needed no group work. cont-init.d completes before services.d, so the account database is already correct when the service execs. The service then starts via exec s6-setuidgid plex …, and s6-setuidgid replaces the process group list from /etc/group rather than appending to it — measured live, Plex Media Server ran with Groups: 44 100 992 1000, where 992 is the host render GID, while root's gid 0 (which PID 1, s6-svscan, does hold) was gone. A Kubernetes supplementalGroups therefore reaches s6-svscan and is discarded before the app ever starts. It would be inert and it would mislead whoever debugs this next. The renderer asserts its own absence in render.py --selftest.

    • A corollary worth having: because the hook re-stats the device on every start, the host GID drifting (a node rebuild renumbering the systemd dynamic render group) is self-healing. No GID is pinned anywhere in this repo.
    • Scope of that measurement, stated so nobody over-reads it. "The group is the image's job" was established for an image that resolves the device group itself. A FUTURE consumer that runs non-root and has no equivalent init hook would still need the group solved somehow — and the answer for that case is a renderer change (per-image handling), not a new ServiceDefinition field. The enum is what keeps that option open, and the 2026-10-01 swap is the proof it works. The first such consumer must re-measure rather than assume this bullet covers it.
  • Deliberately NOT rendered from this field: no privileged: true (Kyverno restrict-host-namespaces is Enforce on app namespaces and would reject it, and it would not have helped anyway — see the EPERM table); no nodeSelector (placement is a separate concern with its own justification — this field is about the device, not about where the pod lands; the extended resource does constrain placement to nodes the plugin advertises, but that is the scheduler's doing, not a selector this renderer writes); no CPU/memory requests or limits (see above — the resources block carries the extended resource and nothing else).

  • Historical, kept so a reader recognises it rather than reintroduces it: type: DirectoryOrCreate on the retired hostPath was load-bearing for that mechanism — it is what made a GPU-less node degrade to software transcoding instead of failing the mount and stalling the app tier. That rationale died with the hostPath; it is not an argument for bringing it back, since the mechanism it protected never granted access in the first place.

  • Absent ⇒ nothing at all is rendered; every service that does not opt in stays byte-identical (no resources key, no volume, no mount, no securityContext). Ignored for kind: vm — a VM receives devices through KubeVirt's own domain.devices.

  • Requirements: R-PLAT-02 (the PaaS provides the host capability; the app declares need).

ingressRoutes[] (host / port) — HTTPRoute + Certificate + DNS

  • Each entry renders an HTTPRoute attaching host → the service port on the shared Gateway.
  • TLS: the Gateway terminates with the cert-manager wildcard *.home.keithjackson.dev certificate (Let's Encrypt via DNS-01, scoped Cloudflare token), so no per-service cert step — trusted, non-self-signed, auto-renewed, including for internal services. Per-service Certificate only where isolation warrants.
  • DNS: ExternalDNS watches the HTTPRoute and manages the <host> record — but only for a public service. An internal service's route is rendered with ExternalDNS's controller annotation set to none, which removes it from ExternalDNS's view entirely (see §exposure), so its <host> exists only in the internal split-horizon answer PiHole serves, never in the public zone. A public record's TARGET is the Cloudflare Tunnel (a proxied CNAME, from the shared Gateway's target annotation; platform/externaldns/README.md), and a public name is not listed in PiHole unless it must stay on the LAN path.
  • The host pattern is enforced to <label>.home.keithjackson.dev by the schema.
  • Requirements: R-TLS-01/02/03 (docs/08), R-DNS-01/02/03 (docs/09).

inClusterClients[] (namespace / port / protocol / name) — direct east-west clients

  • Each entry renders ONE CiliumNetworkPolicy ingress allow on this service: endpointSelector = this service, fromEndpoints matching only the reserved k8s:io.kubernetes.pod.namespace: <namespace> label, toPorts carrying only the declared port/protocol. Name: <service>-allow-<name>-ingress, name defaulting to namespace.
  • Why the field exists. ingressRoutes opens the north-south path only (the gateway allow admits fromEntities: [ingress], the Gateway proxy identity, and nothing else), and dependencies compiles edges only between catalog services — both halves come from a catalog entry's own declaration. A platform controller that dials this service's ClusterIP directly was therefore unauthorable, and under default-deny it is dropped. Prod hit exactly that: its GitRepository points at the in-cluster Gitea Service (http://gitea.gitea.svc.cluster.local:3000/..., clusters/prod/kustomization.yaml) so GitOps depends on no public DNS/Cloudflare/Gateway/${BASE_DOMAIN}, but flux-system's source-controller was denied — and the HTTPRoute that would have served a public name lives in a downstream tier that cannot apply while the source is down (source down → routes never rename → source stays down).
  • Least privilege is part of the contract: a namespace and a port, never fromEntities: all, never a port-less allow. The client is selected by namespace rather than pod labels because an upstream controller's labels are its chart's to change, not this platform's contract — so name a platform-controller namespace whose entire membership may hold that reach, never a shared one.
  • Additive, and not a dependency: the namespace default-deny baseline is unchanged (ingress: []), every undeclared source/port stays denied, and the entry creates no wave ordering edge — the client is a platform component, not a catalog service.
  • Requirements: R-NET-01 (default-deny baseline stands), R-NET-02 (explicit allow / deny undeclared), R-GITOPS-02 (the allowance is authored in the one reviewable object rather than surviving as a live-applied cluster object). Auditable via Hubble (R-SEC-03, R-OBS-04).

auth (mode / groups[] / apiRoutes[]) — identity integration (Keycloak / oauth2-proxy)

  • oidc → register the app as a Keycloak OIDC client (declarative realm/client config, GitOps; client secret SOPS-encrypted). App does its own login redirect.
  • forward-auth → protect the service's ingressRoutes at the Gateway with oauth2-proxy → Keycloak so auth happens before traffic reaches the app (bare UIs without usable OIDC).
  • native → no platform SSO wiring; app owns its auth (documented exception, e.g. Plex/Seerr; and Keycloak itself as the IdP, admin login local + MFA).
  • none → no auth wiring (internal-only, discouraged).
  • groups[] → the Keycloak groups permitted access, enforced by the OIDC client mappers / oauth2-proxy allowed_groups. Schema requires ≥1 group for oidc/forward-auth — access is "in an authorized group," not merely "logged in."
  • apiRoutes[] (forward-auth only) → one repeatable --api-route=<regex> per entry on the per-app oauth2-proxy sidecar, making matching paths answer 401 instead of a 302 to Keycloak. Default [] renders no flag at all, which is the common case. It exists because oauth2-proxy's built-in AJAX guard fires only on an exact Accept: application/json, which these apps' JavaScript does not consistently send: the XHR is 302'd, the browser follows it invisibly, the live SSO session satisfies it silently, and the call resolves 200 carrying the app's HTML where the JavaScript expected JSON — and a POST following a 302 is re-issued as a GET with its body dropped. It is per-service and must never become a renderer constant: the path shape belongs to the app (radarr /signalr/, jackett /api/, pihole none), and a wrong constant does not merely fail to help — a path a browser navigates to, answered 401, shows a logged-out operator a bare error page and never offers a sign-in. Each entry must be anchored at ^/ and continue with literal path characters; ^/, ^/.*, .*, an unanchored /api/ and any alternation are rejected by the schema and by the renderer (alternation is refused rather than supported because the flag is repeatable).
  • Keycloak, oauth2-proxy, and app→Keycloak / proxy→Keycloak / Keycloak→DB flows are ordinary workloads under default-deny with explicit allows (ties back to dependencies).
  • Requirements: R-IDP-02 (protect OIDC-capable and bare UIs), R-IDP-06 (group-based authorization). (docs/15, ADR-0014)

update (channel / range / cadence / autoRollbackOnFailedHealth / pin)

  • container kind → a Flux ImagePolicy (+ ImageRepository) encoding channel/range; Flux image-automation advances the running version on its scan interval (cadence). pin freezes the service (stops automation). No human/AI edits a tag.
  • vm kind → in-guest unattended package updates on cadence (cloud-init), per docs/11 §3.
  • autoRollbackOnFailedHealth: true → health-gated rollout that reverts on failed health and alerts.
  • Requirements: R-UPD-01 (stable only), R-UPD-02 (no human/AI authoring), R-UPD-03 (health-gated auto-rollback). (docs/11, ADR-0009)

observability (metrics / slis[] / metricsEndpoint / livenessWorkload) — ServiceMonitor + alerts

  • metrics: true → the platform collects signals for this service: logs to Loki via the node agent, and kube-state-metrics readiness for the up SLI below. Both are platform-wide scrapes; neither needs a per-service CR. It does not assert that the workload serves a Prometheus endpoint. (Corrected 2026-09-13. It used to render a ServiceMonitor unconditionally, hardcoded to a Service port named metrics. No rendered Service ever defined such a port, so all twelve app monitors discovered ZERO targets — verified on prod: 24 jobs in up, not one of the twelve — and every up{job=...} SLI built on them was structurally inert. metrics: true could not have been right: Plex, Pi-hole, Jackett, qBittorrent, Sonarr, Radarr, Seerr, Semaphore and Nextcloud serve no Prometheus endpoint at all without a separate exporter.)
  • metricsEndpoint: {port, name?, path?, interval?} → the workload does serve metrics, here. Renders all three halves of a working scrape from the one declaration, because any one of them missing makes the scrape silently yield nothing:
    1. a named Service port (name, default metrics) on that port;
    2. a ServiceMonitor endpoint selecting that port name, on path at interval;
    3. a CiliumNetworkPolicy admitting the monitoring namespace to that port — the namespace default-deny baseline is ingress: [], so without it the scrape is dropped by policy. The metrics port is deliberately not added to the Gateway ingress allow: it is east-west for Prometheus, never north-south.
  • each entry in slis[] → at least one alert rule routed by Alertmanager to email; a per-service Grafana dashboard row. up → a <name>-down critical alert reading the readiness of livenessWorkload from kube-state-metrics (kube_deployment_status_replicas_available, kube_statefulset_status_replicas_ready, kube_daemonset_status_number_available) or, for a VirtualMachineInstance, the existence of kubevirt_vmi_info{phase="running"}. Each rule carries an or absent(...) half, so the signal DISAPPEARING fires the alert rather than silencing it.
  • livenessWorkload: {kind, name} → which object's readiness is this service. Defaults to the object this SD renders; override it when the running workload is a different one (keycloak's server is a StatefulSet owned by the Keycloak operator — a Deployment-shaped rule would absent() and fire forever).
  • Requirements: R-OBS-01 (per-service SLIs/alerts). Platform-health alerts (R-OBS-04) are rendered platform-wide, not per ServiceDefinition. (docs/10)

backup (policy)

  • a schedule ref (e.g. daily-offcluster) → Velero + Longhorn off-cluster backup schedule for this service's volumes/namespace.
  • literal reacquirable → no 1:1 backup (bulk media only). Irreplaceable state (configs, DBs, Nextcloud, Gitea, PiHole config, Keycloak DB) MUST NOT be reacquirable.
  • Requirements: R-STATE-03 (off-cluster DR backup). (docs/04)

Cross-field invariants the renderer / L0-L1 tests rely on

  1. kind ↔ source: container⇒image, vm⇒vmTemplate (schema-enforced).
  2. auth ↔ groups: oidc/forward-auth ⇒ non-empty groups (schema-enforced; R-IDP-06). 2a. auth ↔ apiRoutes: a non-empty auth.apiRoutes ⇒ auth.mode: forward-auth (schema-enforced). It renders onto the per-app oauth2-proxy sidecar, and no sidecar exists for oidc/native/none, so declaring it elsewhere would be a silent no-op — the operator believes the app's XHRs now answer 401 and nothing changed. Every entry must be anchored at ^/ (schema-enforced and enforced in catalog/render.py, so a breadth mistake is caught by render-golden even on a run where the schema stage did not execute).
  3. default-deny is unconditional: every namespace gets the deny baseline regardless of dependencies; dependencies only adds allows (R-NET-01/02). The same holds for inClusterClients: it adds one namespace+port ingress allow and never relaxes the baseline.
  4. egress is enforced outside the workload: vpn renders gateway steering + kill-switch, never a per-service sidecar the app could bypass (R-NET-05/06).
  5. TLS is never self-signed and needs no per-service step: the wildcard Gateway cert covers every ingressRoutes.host, internal included (R-TLS-01/03).
  6. irreplaceable ⇒ off-cluster backup: DR-critical services must not carry backup.policy: reacquirable (R-STATE-03). (Advisory: not enforced by the schema — an L0 policy check should assert it; see "Ambiguities / escalations" in schemas/README.md.)
  7. exposure: internal ⇒ no public DNS record, structurally. Every HTTPRoute rendered for an internal service (including one that omits exposure and takes the default) carries external-dns.alpha.kubernetes.io/controller: none, and no public service's route carries it. Derived from exposure alone; enforced on every gate run by conformance/tests/l0-static/internal-services-publish-no-public-dns (R-DNS-03).
  8. hardware is inert until opted into, and its mechanism is renderer-local — now DEMONSTRATED, not merely claimed. An SD with no hardware key renders no resources block, no volume and no volumeMount; an SD with it gains a gpu.intel.com/i915: 1 limit on the app container only (never a sidecar) and no securityContext either. All directions are proven by render.py --selftest, and each assertion is mutation-tested. The resource name gpu.intel.com/i915 appears in catalog/render.py and nowhere else in the catalog path, so the capability's implementation can be replaced without touching a single ServiceDefinition — which is exactly what happened on 2026-10-01, when the mechanism moved from a /dev/dri hostPath (measured non-functional: device-cgroup EPERM) to the device plugin's extended resource with zero catalog edits. The cost of that mechanism is recorded in §hardware: the workload is now Unschedulable when the resource is absent, which stalls the app tier, and the escape hatch is to delete the hardware: block and re-render.

Requirement traceability (contract-level)

Primary R-GITOPS-02 (single reviewable object wires every requirement). Field-level: R-NET-01/02 (dependencies, inClusterClients), R-NET-05/06 (egress), R-STATE-01/02/04 (storage), R-STATE-03 (backup), R-TLS-01/02/03 + R-DNS-01/02/03 (ingressRoutes + exposure), R-OBS-01 (observability), R-UPD-01/02/03 (update), R-IDP-02/06 (auth), R-PLAT-02/03 (kind/source).