PR #28 fixed the cookie collision, the 5-minute session and the missing metrics
listener in one renderer function, and deliberately declined `--api-route`
because the config model could not express the difference between two apps.
This adds the hook it was missing and sets the flag only where a measurement
says to.
THE DEFECT. oauth2-proxy answers an unauthenticated request with a 302 to
Keycloak. Its AJAX guard would send 401 instead, but it fires only on an EXACT
`Accept: application/json`, which these apps' JavaScript does not consistently
send. So a browser XHR gets a 302, FOLLOWS IT INVISIBLY, has it satisfied
silently by the live SSO session, and resolves 200 carrying the app's HTML where
the JavaScript expected JSON. On a write it is worse: a browser following a 302
re-issues a POST as a GET and DROPS THE BODY, so the operator's change is
discarded and the UI reports success.
MEASURED, on two apps and two different paths -- which is the whole argument:
jackett 2026-09-30: 33 x 302 vs 2 x 401 on /api/ paths, and 4 of 6 POSTs
answered by the proxy and never reaching Jackett (upstream `-`).
radarr 2026-10-01, 23h after PR #28 deployed (pod radarr-7d55cbffb9-42hmh,
0 restarts): 232 unauthenticated requests in the pod's whole life and
ALL 232 are the same call -- POST /signalr/messages/negotiate from a
browser, 302, upstream `-`. A left-open tab whose session the cookie
rename orphaned, polling forever, silently redirected every time and
NEVER re-prompting. The reported symptom, still in production.
So radarr's broken path is /signalr/ and jackett's is /api/, and PIHOLE SERVES
NO /api/ AT ALL. One function renders all five sidecars, so the cheap fix is a
constant -- and a constant applies one app's measurement to four apps nobody
measured. `auth` was `additionalProperties: false`, so there was no per-app
hook. Adding one named, documented key is the change; the block stays closed.
schemas/servicedefinition.schema.json 1.8.0 -> 1.9.0: optional
`auth.apiRoutes`, a list (radarr needs two, and --api-route is natively
repeatable) of anchored path regexes. Plus the shape invariant that a
non-empty list requires mode: forward-auth, since no sidecar is rendered
for oidc/native/none and declaring it there would be a SILENT NO-OP.
catalog/render.py emits one --api-route per entry, in order, and RAISES on
an unanchored entry. The flag is SINGULAR -- verified against the pinned
v7.8.1 binary's own --help; --api-routes does not exist and the proxy
refuses to start with it.
catalog/{jackett,radarr}.yaml the two measured apps.
catalog/{pihole,sonarr,qbittorrent}.yaml comments recording that they get
NOTHING, and why. Sonarr and qBittorrent were measured (0 unauthenticated
requests in 23h) and left alone: that is evidence of no TRAFFIC, not of no
need, and sonarr being nearly radarr's codebase is what makes the guess
tempting and still a guess.
apps/wave-0/jackett.yaml, apps/wave-1/radarr.yaml regenerated. Only TWO
goldens change; pihole/qbittorrent/sonarr are byte-identical to main,
which is the per-app property proving itself on the real tree.
BREADTH IS THE DANGEROUS DIRECTION AND IT IS NOT SYMMETRIC. A missing
--api-route is a silent 302. A too-broad one answers a plain PAGE LOAD with
401, so a logged-out operator gets a bare error page and is never offered a
sign-in -- the service looks permanently broken. Each entry must therefore
match `^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$`: anchored at ^/ then literal path
characters. `^/`, `^/.*`, `.*`, `/api/` and `^/api/|^/` are all rejected, all
five provoked. Alternation is refused rather than supported because the flag is
repeatable, so one entry per route costs nothing.
NEW GATE CHECK: conformance/tests/l0-static/oauth2-proxy-api-routes-per-app
(whole-repo scope, so ci/l0.sh's --include-scope union runs it on every
invocation; no bespoke stage, which whole-repo-invariants-wired would fail).
It is a SIBLING of oauth2-proxy-cookie-names-distinct, not an extension of it:
that check's subject is cookie IDENTITY and shares neither inputs nor failure
mode with this, so bundling them would make its name a lie and let a future
api-route decision be expressed only by weakening a COOKIE check.
The check exists for one reason the other gates cannot cover: render-golden
compares the render to the goldens, so a RENDERER CONSTANT plus regenerated
goldens AGREE WITH EACH OTHER while both being wrong. Provoked: the regen exits
0 -- render-golden is happy -- and this check exits 1 with 15 failures naming
pihole, qbittorrent, radarr and sonarr individually. It also asserts the
anchoring pattern is the SAME in the schema and the renderer, because three
surfaces guard this and if they drift the loosest wins silently.
Tested in both directions on throwaway trees outside the repo: compliant tree
rc 0 / 43 assertions / 6 sidecars; pre-change tree rc 1 / 11 failures; and four
targeted mutations (hand-edited golden, widened regex, renderer constant,
deleted schema pattern) each firing their own assertion class and nothing else.
ALSO FOLDED IN (coordinator's request; defect is in the file I own). _selftest()
reached _cookie_name() through a bare `next(genexpr)` with NO default, so
deleting the --cookie-name emission -- the exact mutation that assertion exists
to catch -- raised StopIteration, which escaped _selftest() BEFORE its collected
`SELFTEST FAIL:` lines were printed. Right verdict, zero diagnostics, and it
masked every other assertion the same mutation broke (found by Desmond Oyelaran
while wiring the selftest into the render-golden stage; his PR #33 is not in this
base -- origin/main @ cdf3e14 drives the selftest from no stage, checked). Fixed
with `next(..., None)` plus an explicit check(), so a missing flag reports as a
NAMED assertion. Provoking it exposed three more instances of the same class in
the same block -- `side = fa_containers[1]`, `side["env"][0]`, and the fa_svc
lookup all raise rather than assert when the sidecar is absent -- so all four are
now None-tolerant, and the two fixtures added for --api-route were written that
way from the start. Measured on throwaway copies:
--cookie-name deleted: before rc 1 / 0 FAIL lines / StopIteration
after rc 1 / 3 FAIL lines / no traceback
sidecar not appended: before rc 1 / 0 FAIL lines / IndexError
after rc 1 / 20 FAIL lines / no traceback
Goldens are unchanged by this part; it touches _selftest() only.
NOT verified, in full in the handoff entry: no browser round-trip by anyone for
this flag -- that a 401 REPAIRS the operator's experience is reasoned from HTTP
semantics, not watched. `^/api/` on radarr is PR #30's measurement of radarr's
pre-fix pod, not mine; the current pod has zero /api/ traffic. Sonarr and
qBittorrent are UNMEASURED, not cleared. --cookie-refresh is still unexercised
(0 AuthSuccess on all five pods). Nothing was applied; the live pods are still
the pre-change spec.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
543 lines
35 KiB
JSON
543 lines
35 KiB
JSON
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
|
"title": "ServiceDefinition",
|
|
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.9.0 (adds optional `auth.apiRoutes` -- a per-service list of anchored path regexes the forward-auth oauth2-proxy sidecar answers with 401 instead of a 302 to Keycloak, rendered as one repeatable `--api-route` per entry. The `auth` block stays `additionalProperties: false`; this is one named, documented key, not an opened door. Without it there was NO per-app override hook at all, so the only way to answer an app's XHRs with 401 was a renderer-wide constant -- and the path shape is a property of the app: jackett's is `/api/` (33 x 302 vs 2 x 401 measured 2026-09-30, 4 of 6 POSTs answered by the proxy and never reaching the app), radarr's is `/signalr/` (232 x 302 measured 2026-10-01, every one a POST from a left-open browser tab that is never re-prompted), and pihole serves no `/api/` at all. Each entry must be anchored at `^/` and continue with literal path characters, because a too-broad regex turns a plain page load into a 401 and the service looks permanently broken to a logged-out user. Enforced on every gate run by ci/l0.sh's schema stage over catalog/*.yaml, by catalog/render.py refusing to render an unanchored entry, and by conformance/tests/l0-static/oauth2-proxy-api-routes-per-app)). 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with just two things -- a /dev/dri hostPath volume (`DirectoryOrCreate`) and a read-write /dev/dri volumeMount on the app container -- and a later move to the Intel GPU device plugin is then a render.py change with no catalog edit. It sets NO podSecurityContext.supplementalGroups: measured in-container on prod 2026-09-30, the pms-docker cont-init hook adds the app user to the device's group inside the container and `s6-setuidgid` then REPLACES the process group list from /etc/group, discarding anything Kubernetes supplied, so such an entry would be inert and misleading. Renders NOTHING when absent, so every existing app stays byte-identical. Emits no privileged securityContext (Kyverno restrict-host-namespaces would reject it and it is not needed), no nodeSelector (node placement is a separate concern with its own justification) and no resource requests). 1.7.0 (no field changes; a MAPPING change to `exposure`, which had never had an effect on DNS. `exposure: internal` now renders ExternalDNS's own controller annotation onto every HTTPRoute it emits, set to a value other than `dns-controller`, so external-dns (v0.21.0, source/gateway.go -> annotations.IsControllerMismatch) skips the route before resolving any hostname and the service is INCAPABLE of producing a public record. Both docs/09 R-DNS-03 and schemas/renderer-contract.md already said `internal` renders no public DNS record; nothing enforced it, and six internal services -- jackett, pihole, qbittorrent, radarr, semaphore, sonarr -- were publishing `<name>.home.keithjackson.dev` into the PUBLIC Cloudflare zone answering with the RFC1918 Gateway VIP, which published the internal subnet and a full service inventory and made each name a DNS-rebind primitive. Enforced on every gate run by conformance/tests/l0-static/internal-services-publish-no-public-dns). 1.6.0 (splits `observability.metrics` from WHERE metrics live: adds optional `metricsEndpoint` (port/name/path/interval) and optional `livenessWorkload` (kind/name). `metrics: true` previously rendered a ServiceMonitor hardcoded to a port named `metrics` that no rendered Service ever defined, so all twelve app monitors discovered ZERO targets and every `up{job=...}` SLI was structurally inert on prod; a monitor is now rendered only from a declared `metricsEndpoint`, which also renders the Service port and the monitoring-namespace ingress allow, and the `up` SLI is expressed against kube-state-metrics -- a signal that exists for every workload without a per-app exporter. `livenessWorkload` names the object whose readiness IS the service, for the case where the running workload is not the one this SD renders). 1.5.0 (adds optional `inClusterClients` — additional in-cluster clients, named by NAMESPACE, admitted to a declared port of this service DIRECTLY, bypassing the shared Gateway. `ingressRoutes` admits only the Gateway proxy identity and `dependencies` only links catalog services, so a PLATFORM controller dialling a Service ClusterIP had no authorable form at all; that gap is what deadlocked prod's GitOps when its GitRepository moved to the in-cluster Gitea Service. Renders one least-privilege ingress CiliumNetworkPolicy per entry — the named namespace and the declared port, nothing wider). 1.4.0 (makes the `bulk` tier REAL: a bulk storage entry now carries `export` (the absolute NFS export path on the ${BULK_NFS_SERVER} host) and renders a STATIC, pre-bound NFS PersistentVolume + PVC over data that already exists outside the cluster, plus optional `subPath` and `readOnly` on any entry. Resolves the OQ-2 bulk-backing stub for STATIC pre-existing datasets; dynamic bulk provisioning remains unimplemented and unneeded). 1.3.0 added optional pod-level podSecurityContext for the container kind — k8s PodSecurityContext emitted verbatim onto the pod spec; its fsGroup unblocks non-root images e.g. semaphore/UID-1001 writing a mounted Longhorn PVC). 1.2.0 added optional per-container env/envFrom/lifecycle for the container kind — k8s EnvVar/EnvFromSource/Lifecycle shapes emitted verbatim onto the app container; unblocks headless-install/DB-backed apps e.g. gitea/nextcloud/semaphore/unifi/minecraft. 1.1.0 added the shape invariant exposure:public => auth.mode!=none (docs/13 v1.1 / docs/15 / R-IDP-02).",
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name", "source"],
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "DNS label -> <name>.${BASE_DOMAIN}, i.e. <name>.home.keithjackson.dev on dev and <name>.prod.home.keithjackson.dev on prod (docs/13, docs/09). Must be a valid RFC 1123 DNS label.",
|
|
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
|
"minLength": 1,
|
|
"maxLength": 63
|
|
},
|
|
"kind": {
|
|
"type": "string",
|
|
"description": "PaaS container (default) or IaaS VM via KubeVirt (docs/13; R-PLAT-02/03).",
|
|
"enum": ["container", "vm"],
|
|
"default": "container"
|
|
},
|
|
"source": {
|
|
"type": "object",
|
|
"description": "Workload source. container => image; vm => vmTemplate (CDI source). Exactly one, matching kind.",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"properties": {
|
|
"image": {
|
|
"type": "string",
|
|
"description": "Container image reference (repo/image[:tag]). Required when kind=container.",
|
|
"minLength": 1
|
|
},
|
|
"vmTemplate": {
|
|
"type": "string",
|
|
"description": "KubeVirt/CDI source image reference. Required when kind=vm.",
|
|
"minLength": 1
|
|
}
|
|
}
|
|
},
|
|
"env": {
|
|
"type": "array",
|
|
"description": "Optional per-container environment variables for the container kind (docs/13). Mirrors the k8s EnvVar shape and is emitted verbatim onto the app container: each entry is either a literal {name, value} or a {name, valueFrom: {secretKeyRef|configMapKeyRef}} sourced from a Secret/ConfigMap key. Absent => no env (existing apps render byte-identical). Ignored for kind=vm (VMs configure via cloud-init).",
|
|
"default": [],
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name"],
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Environment variable name (C_IDENTIFIER).",
|
|
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
},
|
|
"value": {
|
|
"type": "string",
|
|
"description": "Literal value. Mutually exclusive with valueFrom."
|
|
},
|
|
"valueFrom": {
|
|
"type": "object",
|
|
"description": "Source the value from a Secret or ConfigMap key (exactly one of secretKeyRef/configMapKeyRef).",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"maxProperties": 1,
|
|
"properties": {
|
|
"secretKeyRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name", "key"],
|
|
"properties": {
|
|
"name": { "type": "string", "minLength": 1 },
|
|
"key": { "type": "string", "minLength": 1 }
|
|
}
|
|
},
|
|
"configMapKeyRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name", "key"],
|
|
"properties": {
|
|
"name": { "type": "string", "minLength": 1 },
|
|
"key": { "type": "string", "minLength": 1 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"oneOf": [
|
|
{ "required": ["value"], "not": { "required": ["valueFrom"] } },
|
|
{ "required": ["valueFrom"], "not": { "required": ["value"] } }
|
|
]
|
|
}
|
|
},
|
|
"envFrom": {
|
|
"type": "array",
|
|
"description": "Optional bulk env sources for the container kind. Mirrors the k8s EnvFromSource shape and is emitted verbatim onto the app container: each entry pulls ALL keys from a Secret or ConfigMap (exactly one of secretRef/configMapRef). Absent => no envFrom (existing apps render byte-identical). Ignored for kind=vm.",
|
|
"default": [],
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"maxProperties": 1,
|
|
"properties": {
|
|
"secretRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name"],
|
|
"properties": { "name": { "type": "string", "minLength": 1 } }
|
|
},
|
|
"configMapRef": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name"],
|
|
"properties": { "name": { "type": "string", "minLength": 1 } }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"lifecycle": {
|
|
"type": "object",
|
|
"description": "Optional container lifecycle hooks for the container kind (docs/13). Mirrors the k8s Lifecycle shape and is emitted verbatim onto the app container. Used for idempotent break-glass/bootstrap steps (e.g. gitea's postStart exec that seeds a local admin). Absent => no lifecycle (existing apps render byte-identical). Ignored for kind=vm.",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"properties": {
|
|
"postStart": { "$ref": "#/$defs/execHandler" },
|
|
"preStop": { "$ref": "#/$defs/execHandler" }
|
|
}
|
|
},
|
|
"podSecurityContext": {
|
|
"type": "object",
|
|
"description": "Optional pod-level SecurityContext for the container kind (docs/13). Mirrors the k8s PodSecurityContext shape and is emitted verbatim onto the pod spec (spec.template.spec.securityContext). Its primary use is `fsGroup`, which makes a mounted Longhorn PVC group-writable by a non-root image (e.g. semaphore runs as UID 1001 and cannot otherwise write its config PVC). Absent => no pod securityContext (existing apps render byte-identical). Ignored for kind=vm.",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"properties": {
|
|
"runAsUser": { "type": "integer", "description": "UID the container processes run as." },
|
|
"runAsGroup": { "type": "integer", "description": "Primary GID the container processes run as." },
|
|
"runAsNonRoot": { "type": "boolean", "description": "Require the container to run as a non-root UID (validated at admission)." },
|
|
"fsGroup": { "type": "integer", "description": "Supplemental group applied to mounted volumes so they are group-owned + group-writable (chowned by the kubelet), letting a non-root image write a PVC." },
|
|
"fsGroupChangePolicy": { "type": "string", "enum": ["Always", "OnRootMismatch"], "description": "When the kubelet recursively applies fsGroup ownership to a volume." },
|
|
"supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." }
|
|
}
|
|
},
|
|
"hardware": {
|
|
"type": "object",
|
|
"description": "Optional host HARDWARE CAPABILITY the workload needs, for the container kind (docs/13). This field declares INTENT (`what device class this service needs`), never MECHANISM (`which host path to bind-mount`, `which extended resource to request`). That separation is the whole point of the enum: the platform currently satisfies `intel-quicksync` with a /dev/dri hostPath volume and a matching mount on the app container, but a later migration to the Intel GPU device plugin (`gpu.intel.com/i915` resource requests, no hostPath) must be a catalog/render.py change and NOTHING else - no ServiceDefinition may need editing, and no reviewer may have to re-derive per-service device wiring. Do NOT 'simplify' this into a raw device path, a hostPath, or a resource request; that would push a platform implementation detail into the operator-facing contract and make the migration a catalog-wide edit. Absent => no device wiring at all (existing apps render byte-identical). Ignored for kind=vm (a VM gets devices through KubeVirt's own domain.devices).",
|
|
"additionalProperties": false,
|
|
"minProperties": 1,
|
|
"properties": {
|
|
"gpu": {
|
|
"type": "string",
|
|
"description": "GPU capability class this service needs. `intel-quicksync` = the host's Intel Quick Sync Video render device (the iGPU's /dev/dri/renderD* node) for hardware video transcode/decode. Enum, not a free-form device path, so the satisfying mechanism stays swappable (see the parent description). Extend the enum when a genuinely different capability class appears (e.g. nvidia-nvenc), never to name a different path to the same one.",
|
|
"enum": ["intel-quicksync"]
|
|
}
|
|
}
|
|
},
|
|
"exposure": {
|
|
"type": "string",
|
|
"description": "internal = LAN-only (internal DNS + Gateway on LAN); public = additionally edge port-forwarded 80/443 (docs/09; R-DNS/R-NET).",
|
|
"enum": ["internal", "public"],
|
|
"default": "internal"
|
|
},
|
|
"egress": {
|
|
"type": "string",
|
|
"description": "Egress class (docs/03; R-NET-05). internet = default WAN route; vpn = steered by Cilium Egress Gateway out the egress-gateway node's host PIA VPN tunnel, with a mandatory host kill-switch (ADR-0015; R-NET-06).",
|
|
"enum": ["internet", "vpn"],
|
|
"default": "internet"
|
|
},
|
|
"storage": {
|
|
"type": "array",
|
|
"description": "Persistent volumes. If it must survive a rebuild it lives here, never on ephemeral/hostPath (docs/04; R-STATE-01/02/04). A `longhorn` entry renders a dynamically provisioned PVC; a `bulk` entry renders a STATIC, pre-bound NFS PersistentVolume + its PVC over data that already exists outside the cluster.",
|
|
"default": [],
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["name", "class"],
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Volume identifier within the service (e.g. config, media, db).",
|
|
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
|
|
},
|
|
"size": {
|
|
"type": "string",
|
|
"description": "PVC request in Kubernetes quantity form (e.g. 5Gi). Required for the longhorn tier. For the bulk tier it is the STATIC PV's advertised capacity: NOTHING enforces it (the ZFS pool is the real limit), so state the dataset's current size so `kubectl get pv` reports something true. Omitted => the bulk PV/PVC carry no capacity, which the API rejects, so bulk in practice sets it.",
|
|
"pattern": "^[1-9][0-9]*(Mi|Gi|Ti)$"
|
|
},
|
|
"mountPath": {
|
|
"type": "string",
|
|
"description": "Optional explicit absolute container mount path for this volume. Default is /data/<name>. Set it when the app expects a FIXED on-disk layout the default cannot express (e.g. Gitea keeps all state directly under /data). Renderer honors this verbatim; omitting it preserves the /data/<name> default for back-compat (R-STATE-01).",
|
|
"pattern": "^/.*[^/]$"
|
|
},
|
|
"class": {
|
|
"type": "string",
|
|
"description": "longhorn = 3-replica block storage for app/config/DB state (R-STATE-02); bulk = large external media tier, NOT 3x replicated, backed by a STATIC NFS PersistentVolume the renderer emits (requires `export`).",
|
|
"enum": ["longhorn", "bulk"],
|
|
"default": "longhorn"
|
|
},
|
|
"export": {
|
|
"type": "string",
|
|
"description": "bulk ONLY: the absolute NFS export path on the bulk server (e.g. /mnt/Storage/plex). The SERVER is NOT named here - it is the ${BULK_NFS_SERVER} cluster-vars knob, so the same ServiceDefinition promotes across environments (R-ENV-02). Required whenever class is bulk; forbidden otherwise.",
|
|
"pattern": "^/.*[^/]$"
|
|
},
|
|
"subPath": {
|
|
"type": "string",
|
|
"description": "Optional relative path WITHIN the volume to mount instead of its root. NOTE for hardlink/atomic-move workloads (Sonarr/Radarr): two subPath mounts of the same export are two separate mounts, so link() across them fails EXDEV. Mount the common ancestor once instead.",
|
|
"pattern": "^[^/].*[^/]$"
|
|
},
|
|
"readOnly": {
|
|
"type": "boolean",
|
|
"description": "Mount this volume read-only. For a bulk volume this also sets the PersistentVolume's nfs.readOnly (the mount really is `ro`) and makes its access mode ReadOnlyMany instead of ReadWriteMany.",
|
|
"default": false
|
|
}
|
|
},
|
|
"allOf": [
|
|
{
|
|
"if": { "properties": { "class": { "const": "bulk" } }, "required": ["class"] },
|
|
"then": { "required": ["export"] },
|
|
"else": { "not": { "required": ["export"] } }
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"dependencies": {
|
|
"type": "array",
|
|
"description": "Explicit east-west allow rules; everything else is denied (docs/03; R-NET-01/02). Empty => the service can reach only DNS.",
|
|
"default": [],
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["to", "port"],
|
|
"properties": {
|
|
"to": {
|
|
"type": "string",
|
|
"description": "Destination service name (another catalog entry or a co-located component such as this service's DB).",
|
|
"minLength": 1
|
|
},
|
|
"port": {
|
|
"type": "integer",
|
|
"description": "Destination TCP/UDP port.",
|
|
"minimum": 1,
|
|
"maximum": 65535
|
|
},
|
|
"protocol": {
|
|
"type": "string",
|
|
"enum": ["tcp", "udp"],
|
|
"default": "tcp"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"ingressRoutes": {
|
|
"type": "array",
|
|
"description": "Hostnames/ports to expose north-south; rendered to HTTPRoute + Certificate + DNS (docs/08, docs/09; R-DNS/R-TLS).",
|
|
"default": [],
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["host", "port"],
|
|
"properties": {
|
|
"host": {
|
|
"type": "string",
|
|
"description": "Hostname under the platform zone, as a single DNS label plus the ${BASE_DOMAIN} token — e.g. 'gitea.${BASE_DOMAIN}' (docs/09). ${BASE_DOMAIN} is the per-environment hostname suffix from clusters/base/cluster-vars.yaml, resolved by Flux postBuild substitution at reconcile time (dev: home.keithjackson.dev, prod: prod.home.keithjackson.dev). AUTHOR THE TOKEN, NOT A LITERAL: a literal suffix is what let prod be authored to serve dev's public hostnames out of the shared Cloudflare zone, and prod's external-dns then deleted dev's records (infra/PROD-PROVISIONING.md §10). The literal 'home.keithjackson.dev' form stays accepted so the L1 render fixtures — which are renderer INPUTS, not reconciled manifests — keep validating unchanged.",
|
|
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.(\\$\\{BASE_DOMAIN\\}|home\\.keithjackson\\.dev)$"
|
|
},
|
|
"port": {
|
|
"type": "integer",
|
|
"description": "Backing Service port the route targets.",
|
|
"minimum": 1,
|
|
"maximum": 65535
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"inClusterClients": {
|
|
"type": "array",
|
|
"description": "Additional IN-CLUSTER clients, identified by their NAMESPACE, permitted to reach this service's port DIRECTLY — east-west, bypassing the shared Gateway (docs/03; R-NET-01/02). Neither existing field can express this: `ingressRoutes` opens only the north-south path from the Gateway proxy identity (`fromEntities: [ingress]`), and `dependencies` compiles edges only between CATALOG services. A PLATFORM controller that dials this service's ClusterIP is therefore unauthorable — which is exactly how prod's GitOps deadlocked once its GitRepository moved to the in-cluster Gitea Service (source-controller DENIED => the source cannot reconcile => the downstream tier that would fix it never applies). Each entry renders ONE least-privilege ingress CiliumNetworkPolicy: this service's endpointSelector, `fromEndpoints` matching ONLY the named namespace, on ONLY the declared port — never `fromEntities: all`. Additive: the default-deny baseline still denies everything undeclared (R-NET-01). It is NOT a dependency edge and creates no wave ordering — the client is a platform component, not a catalog service.",
|
|
"default": [],
|
|
"uniqueItems": true,
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["namespace", "port"],
|
|
"properties": {
|
|
"namespace": {
|
|
"type": "string",
|
|
"description": "The client's namespace, and the WHOLE identity that is matched (`k8s:io.kubernetes.pod.namespace`): every pod in it is admitted on the declared port. Name a platform-controller namespace whose entire membership may legitimately hold that reach (flux-system, monitoring), never a shared or multi-tenant one.",
|
|
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
|
"maxLength": 63
|
|
},
|
|
"port": {
|
|
"type": "integer",
|
|
"description": "The port on THIS service the client may reach (the Service/container port). One entry per port; an undeclared port stays denied.",
|
|
"minimum": 1,
|
|
"maximum": 65535
|
|
},
|
|
"protocol": {
|
|
"type": "string",
|
|
"enum": ["tcp", "udp"],
|
|
"default": "tcp"
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Short client name used in the rendered policy name `<service>-allow-<name>-ingress`. Defaults to `namespace`. It exists because that name is operator-facing — it is what `kubectl get cnp` and a Hubble drop show — and because an allowance already applied live can then be authored under its EXISTING name, so Flux adopts the live object in place instead of creating a near-duplicate and leaving an unmanaged leftover (gitea/flux-system: `gitea-allow-flux-ingress`).",
|
|
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
|
"maxLength": 40
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"auth": {
|
|
"type": "object",
|
|
"description": "SSO integration (docs/15, ADR-0014; R-IDP-02/06). Default posture for any exposed UI is forward-auth unless it declares native OIDC (oidc) or a documented exception (native).",
|
|
"additionalProperties": false,
|
|
"required": ["mode"],
|
|
"properties": {
|
|
"mode": {
|
|
"type": "string",
|
|
"description": "oidc = app is a Keycloak OIDC client (own login); forward-auth = oauth2-proxy -> Keycloak at the Gateway; native = app manages its own auth (documented exception, e.g. Plex/Seerr, or Keycloak itself as the IdP); none = no auth (internal-only, discouraged).",
|
|
"enum": ["oidc", "forward-auth", "native", "none"],
|
|
"default": "forward-auth"
|
|
},
|
|
"groups": {
|
|
"type": "array",
|
|
"description": "Keycloak groups permitted access (R-IDP-06). Required and non-empty for oidc/forward-auth (least privilege: authenticated AND in an authorized group). N/A for native/none.",
|
|
"items": { "type": "string", "minLength": 1 },
|
|
"uniqueItems": true,
|
|
"default": []
|
|
},
|
|
"apiRoutes": {
|
|
"type": "array",
|
|
"description": "forward-auth ONLY: path regexes this app serves as a MACHINE API, which the oauth2-proxy sidecar must answer with 401 instead of a 302 to Keycloak (one --api-route per entry; the flag is repeatable and singular -- --api-routes does not exist in v7.8.1 and the proxy refuses to start with it). WHY THIS IS PER-APP AND NOT A RENDERER CONSTANT: oauth2-proxy's built-in AJAX guard fires only on an EXACT `Accept: application/json`, which these apps' JavaScript does not consistently send, so an unauthenticated XHR gets a 302 that the browser follows invisibly, the live Keycloak SSO session satisfies silently, and the call resolves 200 carrying the app's HTML where the JavaScript expected JSON -- no prompt, no error, and on a write it is worse, because a browser following a 302 re-issues a POST as a GET and DROPS THE BODY. Measured on jackett 2026-09-30: 33 x 302 against 2 x 401 on /api/ paths, with 4 of 6 POSTs answered by the proxy and never reaching the app (access-log upstream field `-`). Measured on radarr 2026-10-01, 23h after the cookie fix deployed: 232 x 302, ALL of them POST /signalr/messages/negotiate from the operator's own browser -- a left-open tab whose cookie the rename orphaned, polling forever, silently redirected every time and NEVER re-prompting. But the path shape is a property of the APP, not of the pattern: radarr's broken path is /signalr/ and jackett's is /api/, and pihole serves no /api/ at all, so a renderer-wide constant would apply one app's path shape to five. Declare only paths this app actually serves as JSON/RPC and that a browser never NAVIGATES to. BREADTH IS THE DANGER: too broad and a plain page load answers 401 instead of signing the operator in, i.e. the service looks permanently broken to a logged-out user and no sign-in is ever offered. The item pattern therefore REQUIRES each entry to be anchored at `^/` and to continue with literal path characters, which rejects `^/`, `^/.*`, `.*` and any unanchored or alternated regex; alternation is refused rather than supported because the flag is repeatable, so one regex per route costs nothing and keeps each entry readable. N/A for oidc/native/none (no sidecar is rendered).",
|
|
"items": {
|
|
"type": "string",
|
|
"pattern": "^\\^/[A-Za-z0-9._~/-]+(\\.\\*|\\$)?$"
|
|
},
|
|
"uniqueItems": true,
|
|
"default": []
|
|
}
|
|
}
|
|
},
|
|
"update": {
|
|
"type": "object",
|
|
"description": "Per-service automated-update policy (docs/11; R-UPD-01/02/03). The operator declares the policy once; an automation controller resolves it - no human/AI edits a tag.",
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"channel": {
|
|
"type": "string",
|
|
"description": "Release channel. stable only unless explicitly opted in (R-UPD-01).",
|
|
"default": "stable"
|
|
},
|
|
"range": {
|
|
"type": "string",
|
|
"description": "Acceptable versions: semver range or tag regex (feeds the Flux ImagePolicy)."
|
|
},
|
|
"cadence": {
|
|
"type": "string",
|
|
"description": "How often to check/apply (the schedule; scan interval)."
|
|
},
|
|
"autoRollbackOnFailedHealth": {
|
|
"type": "boolean",
|
|
"description": "Revert automatically on failed health gates (R-UPD-03).",
|
|
"default": true
|
|
},
|
|
"pin": {
|
|
"type": "string",
|
|
"description": "Operator freeze escape hatch: a fixed version that stops automation for this service."
|
|
}
|
|
}
|
|
},
|
|
"observability": {
|
|
"type": "object",
|
|
"description": "Observability intent; rendered to ServiceMonitor/PodMonitor + log scraping + dashboards + alerts (docs/10; R-OBS-01).",
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"metrics": {
|
|
"type": "boolean",
|
|
"description": "Whether the platform collects signals for this service (logs to Loki + kube-state-metrics-derived liveness). It does NOT assert that the workload serves a Prometheus endpoint — say that with metricsEndpoint.",
|
|
"default": true
|
|
},
|
|
"slis": {
|
|
"type": "array",
|
|
"description": "Per-service alert intents (e.g. up). Each becomes at least one alert rule (R-OBS-01).",
|
|
"items": { "type": "string", "minLength": 1 },
|
|
"default": []
|
|
},
|
|
"metricsEndpoint": {
|
|
"type": "object",
|
|
"description": "WHERE this workload serves Prometheus metrics. Absent (the default for every catalog entry today) means it serves none, and NO ServiceMonitor is rendered — the renderer used to emit one naming port `metrics` regardless, which no rendered Service ever defined, so all twelve monitors discovered zero targets and every `up`-based SLI was structurally inert. When present it renders all three halves of a working scrape together: the named Service port, the ServiceMonitor endpoint, and the monitoring-namespace ingress allow that the default-deny baseline would otherwise drop.",
|
|
"additionalProperties": false,
|
|
"required": ["port"],
|
|
"properties": {
|
|
"port": {
|
|
"type": "integer",
|
|
"description": "Container/Service port the metrics endpoint listens on.",
|
|
"minimum": 1,
|
|
"maximum": 65535
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Service port name the ServiceMonitor endpoint selects on.",
|
|
"minLength": 1,
|
|
"default": "metrics"
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": "HTTP path serving the Prometheus exposition format.",
|
|
"minLength": 1,
|
|
"default": "/metrics"
|
|
},
|
|
"interval": {
|
|
"type": "string",
|
|
"description": "Scrape interval (Prometheus duration).",
|
|
"pattern": "^[0-9]+[smh]$",
|
|
"default": "30s"
|
|
}
|
|
}
|
|
},
|
|
"livenessWorkload": {
|
|
"type": "object",
|
|
"description": "WHICH Kubernetes object's readiness constitutes this service being up, for the `up` SLI. Defaults to the object this ServiceDefinition renders (Deployment named after the service for kind: container, VirtualMachineInstance for kind: vm). Override only when the object that actually runs on the cluster is a different one — keycloak's server is a StatefulSet owned by the Keycloak operator, and a Deployment-shaped rule would read an absent series and fire forever.",
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"kind": {
|
|
"type": "string",
|
|
"description": "Workload kind whose kube-state-metrics readiness series the SLI reads.",
|
|
"enum": ["Deployment", "StatefulSet", "DaemonSet", "VirtualMachineInstance"]
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Object name (defaults to the service name).",
|
|
"minLength": 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"backup": {
|
|
"type": "object",
|
|
"description": "Backup posture (docs/04; R-STATE-03). Irreplaceable state MUST be backed up off-cluster; bulk media may be 'reacquirable'.",
|
|
"additionalProperties": false,
|
|
"required": ["policy"],
|
|
"properties": {
|
|
"policy": {
|
|
"type": "string",
|
|
"description": "Reference to an off-cluster backup schedule, or the literal 'reacquirable' for bulk media that is not backed up 1:1.",
|
|
"minLength": 1
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"allOf": [
|
|
{
|
|
"$comment": "kind determines which source field is required (docs/13). Absent kind defaults to container -> image.",
|
|
"if": {
|
|
"required": ["kind"],
|
|
"properties": { "kind": { "const": "vm" } }
|
|
},
|
|
"then": {
|
|
"properties": { "source": { "required": ["vmTemplate"] } }
|
|
},
|
|
"else": {
|
|
"properties": { "source": { "required": ["image"] } }
|
|
}
|
|
},
|
|
{
|
|
"$comment": "oidc/forward-auth require at least one authorized group (R-IDP-06; docs/15 acceptance gate: restricted to the allowed group).",
|
|
"if": {
|
|
"required": ["auth"],
|
|
"properties": {
|
|
"auth": {
|
|
"required": ["mode"],
|
|
"properties": { "mode": { "enum": ["oidc", "forward-auth"] } }
|
|
}
|
|
}
|
|
},
|
|
"then": {
|
|
"properties": {
|
|
"auth": {
|
|
"required": ["groups"],
|
|
"properties": { "groups": { "minItems": 1 } }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"$comment": "auth.apiRoutes only has a consumer under forward-auth: it renders --api-route onto the per-app oauth2-proxy sidecar, and no sidecar is rendered for oidc/native/none. Declaring it elsewhere would be a SILENT NO-OP -- the operator believes the app's XHRs now answer 401 and nothing changed -- so it is rejected at the schema instead (R-IDP-02).",
|
|
"if": {
|
|
"required": ["auth"],
|
|
"properties": {
|
|
"auth": {
|
|
"required": ["apiRoutes"],
|
|
"properties": { "apiRoutes": { "minItems": 1 } }
|
|
}
|
|
}
|
|
},
|
|
"then": {
|
|
"properties": {
|
|
"auth": {
|
|
"required": ["mode"],
|
|
"properties": { "mode": { "const": "forward-auth" } }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"$comment": "public exposure must not be unauthenticated: an explicit auth.mode 'none' is disallowed on public services (docs/13 v1.1, docs/15, R-IDP-02). Absent auth defaults to forward-auth at render time, so it is permitted here.",
|
|
"if": {
|
|
"required": ["exposure"],
|
|
"properties": { "exposure": { "const": "public" } }
|
|
},
|
|
"then": {
|
|
"properties": {
|
|
"auth": {
|
|
"properties": { "mode": { "not": { "const": "none" } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
],
|
|
"$defs": {
|
|
"execHandler": {
|
|
"type": "object",
|
|
"description": "A container lifecycle hook. Only the exec form is supported (a command run inside the container); mirrors k8s LifecycleHandler.exec.",
|
|
"additionalProperties": false,
|
|
"required": ["exec"],
|
|
"properties": {
|
|
"exec": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["command"],
|
|
"properties": {
|
|
"command": {
|
|
"type": "array",
|
|
"description": "Command line to execute inside the container. Not run in a shell unless the command itself invokes one.",
|
|
"minItems": 1,
|
|
"items": { "type": "string" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|