# 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`](../../home-network-k3s/docs/13-service-catalog.md) (the ServiceDefinition schema and default posture). This document maps each field of [`servicedefinition.schema.json`](./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 | | `update.channel` | `stable` | R-UPD-01 | | `update.autoRollbackOnFailedHealth` | `true` | R-UPD-03 | | `observability.metrics` | `true` | 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 `.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 `.home.keithjackson.dev`. - `internal` renders **no public DNS record** and is never port-forwarded (PiHole resolves it to the internal Gateway VIP). - 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** and for **observability scraping** from the monitoring namespace. - 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/` 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) ### `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 `` record (public IP for `public`, internal Gateway VIP via PiHole split-horizon for `internal`). - The `host` pattern is enforced to `