qBittorrent was the one member of the download chain with NO media volume.
Its live config said
Session\DefaultSavePath=/downloads/
Session\TempPath=/downloads/incomplete/
Downloads\SavePath=/downloads/
while `ls /downloads` in the container returned "No such file or directory".
The catalog entry declared exactly one volume — config at /config on longhorn —
and carried a comment deferring the downloads volume "until OQ-2 is resolved".
OQ-2 WAS resolved (schema 1.5.0 static bulk NFS PVs); plex, sonarr, radarr and
nextcloud all took their mounts and qBittorrent's was simply never added. So the
download client was writing, or failing to write, a path that did not exist —
and anything it did create would have been invisible to sonarr/radarr, lost on
every pod recreation, and consuming the node's ephemeral disk until then.
WHAT THIS ADDS to catalog/qbittorrent.yaml (rendered into apps/wave-0):
* storage[downloads]: class bulk, export /mnt/Storage/plex, subPath downloads,
mountPath /downloads, 9Ti advisory. ONE mount of the SAME export sonarr,
radarr (rw) and plex (ro) already consume, narrowed to the handoff subtree.
* env PUID=1000 / PGID=3000. LOAD-BEARING, not cosmetic: the container ran as
uid 911 and /mnt/Storage/plex/downloads is 0755 owned 1000:1000, so the
export would have mounted cleanly and then failed every write with EACCES.
Measured both ways on prod: a write as uid 1000 succeeded, the same write as
uid 911 was denied.
* podSecurityContext.fsGroup 3000, for the LONGHORN config PVC only, exactly as
sonarr/radarr set it — fsGroup is a no-op on an in-tree NFS volume.
WHY subPath AND NOT THE EXPORT ROOT AT /media (sonarr/radarr's shape): a download
client has no business writing the library. The arrs need the root mount because
THEY hardlink downloads/ -> tv/|movies/ and link() is a same-MOUNT operation;
qBittorrent performs no such link, so narrowing to downloads/ removes movies/,
tv/ and photos/ from its mount namespace without removing any operation it
performs. It is still ONE mount of the export, so the L0 assertion that no
workload mounts one export twice (the EXDEV trap) holds — and the arrs' own
link() stays intra-mount. Proven live: nlink 1 -> 2, same inode, no EXDEV, every
step executed as uid 1000.
NETWORK: nothing to grant and nothing to steer. An NFS PersistentVolume is
mounted by the NODE KERNEL in the host netns — the established socket to
192.168.2.102:2049 is on the node's own LAN address, the pod netns has no socket
to 2049 at all, and no CiliumNetworkPolicy on this cluster names that host or
port. `ip route get 192.168.2.102` picks eno1 even on the tunnel-owning node, and
Cilium's own egress map lists 192.168.0.0/16 as an Excluded CIDR for every
vpn-class pod while 0.0.0.0/0 goes to the gateway. Public egress still exits the
PIA address; the VPN guarantee is untouched.
NOT CLOSED BY THIS COMMIT, deliberately: absolute paths still differ between this
pod (/downloads/x) and the arrs (/media/downloads/x), so Completed Download
Handling needs one Remote Path Mapping per arr. That lives in the arrs' own
databases, not in git.
Co-Authored-By: Claude Opus 5 <[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).