I took Plex down for the whole LAN. ADVERTISE_IP was http://plex-vip.${BASE_DOMAIN}:32400 -- a name that exists ONLY in PiHole's split-horizon answers and in no public zone. A Plex client resolves the advertised host ITSELF, with whatever resolver it uses, and on a real LAN that is frequently not PiHole: browsers ship DoH on by default, phones have "private DNS", guest/IoT devices take the router's DNS. Those clients could not resolve the name, the router answered the failed lookup with ITSELF, and they all landed on its web UI -- hence the %D certificate. Measured 2026-10-05: 192.168.2.1:443 -> C=ZZ, O=%D342e7e6b, CN=%D (router, LAN side) 136.51.115.252:443 -> the same %D cert (router, WAN side) pihole-0 AND pihole-1 -> plex -> .20, plex-vip -> .24 (both CORRECT) gateway .20:443 -> CN=*.home.keithjackson.dev, valid http://192.168.2.24:32400/identity -> 200 Plex's published connections: all port 32400, nothing on 443 The DNS was right; the DEPENDENCY on it was the bug. And since nothing Plex advertises listens on 443, the clients had left the Plex path entirely. Fix: <dashed-ip>.<serverhash>.plex.direct, which is RESOLVER-INDEPENDENT -- a public zone returning the private address embedded in the label, so every resolver (PiHole, the router, 1.1.1.1, a DoH browser) answers it with 192.168.2.24. No split-horizon entry, no Cloudflare record and no certificate work needed: the wildcard cert Plex already serves on 32400 covers that name. Re-verified today with full validation (no curl -k): 200, TLS verified. The nicer hostname cannot be made correct for clients that bypass PiHole -- a public record resolves to the WAN address and there is no NAT hairpin, so it is right only for PiHole users and wrong for the rest. There is no configuration of it that is right for both. PiHole's plex-vip answer is KEPT; it is just not what Plex advertises. Cost restated, not buried: the value embeds the VIP and the cert hash as a literal and nothing checks they still agree. Accepted because an unresolvable name is a worse failure -- silent, LAN-wide, and it points users at the router. UNVERIFIED: unreconciled, and the client-side resolver path is INFERRED -- my public-resolver probe was broken (NXDOMAIN even for a name that resolves) so I discarded it. The chain is the only explanation consistent with the measurements, but the NXDOMAIN-hijack step was not observed directly. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
catalog — ServiceDefinition entries (the onboarding surface)
One YAML file per service. Each file is a ServiceDefinition conforming to
../schemas/servicedefinition.schema.json. Adding or
editing a service here is the only onboarding step — the platform renders compute, storage, network
policy, egress, TLS, DNS, monitoring, and update policy from it (see
../schemas/renderer-contract.md). Serves R-GITOPS-02.
Source of truth for the initial catalog: spec
docs/13-service-catalog.md (14-service table +
SSO/egress/dependency intent).
Current examples
| File | docs/13 row | exposure | egress | auth | Notes |
|---|---|---|---|---|---|
pihole.yaml |
6 | internal | internet | forward-auth | admin UI behind oauth2-proxy; config on Longhorn (DR-critical); also platform DNS |
keycloak.yaml |
0 | public | internet | native | the IdP itself (own admin login + MFA); PostgreSQL on Longhorn; DR-critical |
The remaining catalog services (plex, sonarr, radarr, jackett, seerr, nextcloud, minecraft, unifi, semaphore, kasm, gitea, qbittorrent) are onboarded in later phases; they follow the same schema and inherit every guarantee automatically.
Authoring rules
- Only
nameandsourceare mandatory; every other field defaults to docs/13's default posture (kind: container,exposure: internal,egress: internet,auth.mode: forward-auth, stable auto-update, DNS-only default-deny). Set a field only to override the default. - Any exposed UI must have an
authblock:forward-auth(default) oroidc, each with a non-emptyauth.groups;native/noneare documented exceptions only. - Irreplaceable state (
class: longhornconfigs/DBs) must setbackup.policyto an off-cluster schedule — neverreacquirable(R-STATE-03). Only bulk media may bereacquirable. - Shell braces must be escaped
$${...}. Anything rendered verbatim onto the workload — alifecyclepostStart script, anenvvalue — lands underapps/wave-<N>/, and everyapps-wave-<N>Flux Kustomization declarespostBuild.substituteFrom: cluster-vars. Flux runs envsubst over the WHOLE manifest at reconcile time, so an unescaped${VAR:-}meant for the shell is replaced with the empty string before the container ever starts (this silently killed gitea's and nextcloud's OIDC registration for days). Bare$VARand$(cmd)are left alone and need no escaping; a genuine environment knob is declared inclusters/base/cluster-vars.yamland written bare${KNOB}. Full contract:../clusters/base/apps-vms.yaml.render.pyREFUSES to emit a manifest that breaks this rule (Flux's strict substitution mode cannot: it deliberately exempts the${V:-}/${V:=x}/${V:?}default-operator forms).
Validate before commit — single command, no cluster (R-TEST-02)
check-jsonschema --schemafile schemas/servicedefinition.schema.json catalog/*.yaml
See ../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 compiles a ServiceDefinition into the concrete manifest set defined
by ../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).
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 byconformance/run.sh --layer l1andci/l1.sh(no cluster). - How CI/Flux consume it: CI invokes
render.pyin 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 ofrender.py. - Dependency: PyYAML (the renderer's single dependency; the conformance harness stays stdlib-only and only shells out to this script + diffs text).