Widens the cookie-collision fix to the whole sidecar contract, because all three
defects live in the same function and land on the same five goldens, so they are one
atomic change or they are five conflicting ones.
Added to _oauth2_sidecar_container(), on top of the cookie-name/cookie-domain fix:
--cookie-refresh=2m30s
The session died FIVE MINUTES after sign-in. Keycloak's homelab realm issues
300-second access tokens (MEASURED on a live session: expires - created =
300.000s exactly, with refresh_token:true on the same line), and
--cookie-refresh defaults to 0 -- which the pinned binary's own help spells
"0 to disable". So the proxy held a refresh token it was never told to spend.
Refresh at HALF the lifetime: two chances to renew before expiry.
This, not the collision, is what dominates the rejection VOLUME -- ~95% of
radarr's 155 rejections in 12h carry no cookie at all.
--metrics-address=0.0.0.0:44180 + a `metrics` container port
Without the flag the /metrics listener never starts, so no per-app sidecar was
serving metrics at all (`ports` was ['http'] on all six). 44180 is the
platform's existing convention, measured not chosen: the shared proxy already
runs the same flag and port with 2 ready endpoints.
PREREQUISITE ONLY -- NOT scraped. A container port is not a Service port, and
each namespace's default-deny baseline is `ingress: []`, so this is unreachable
until a Service port (or PodMonitor), a monitoring allow and a ServiceMonitor
land. Do not read this as "the sidecars have metrics now".
EVERY FLAG VERIFIED AGAINST THE PINNED v7.8.1 BINARY (`--help`), not against docs or
the shared proxy -- which runs v7.15.3 and could not have vouched for v7.8.1:
* --metrics-address exists in 7.8.1, so this cannot crashloop the pods
* --cookie-refresh ... "0 to disable" confirms the default
* --cookie-name ... (default "_oauth2_proxy") -- this confirms from the
IMPLEMENTATION the constant the new L0 check is built on
* --api-route is SINGULAR; the plural does not exist and the proxy refuses to start
* --session-cookie-minimal exists and is mutually exclusive with --cookie-refresh,
so dropping --pass-access-token is a least-privilege argument, never a size fix
NOT done, deliberately: --api-route=^/api/ renderer-wide. It was measured on jackett
and PiHole does not serve /api/, so a renderer constant would apply one app's path
shape to five. It needs a per-app hook the `auth` schema forbids
(additionalProperties: false) -- a schema change, separate PR.
Also records the mechanism that made this INTERMITTENT, as a renderer comment, because
nobody would reconstruct it from the flags: the loser does not just fail to read the
cookie, it calls ClearSession, which expires the cookie on its own --cookie-domain --
the whole zone. The *arr UIs poll (/api/v3/command, /api/v3/queue/details,
/signalr/negotiate), so every poll carrying an undecryptable cookie clears it again:
A LEFT-OPEN RADARR TAB DELETED JACKETT'S COOKIE EVERY FEW SECONDS, from another
namespace, with no user action. That is why signing in again never helped for longer
than the gap between two polls.
Crafty's counter-evidence resolved: the 81:1 ratio was a 24h window straddling a
13h52m-old pod. Bounded to the fixed process, crafty's ENTIRE life is 3 rejections in
6743 log lines, with ZERO `removing session` and ZERO `cookie signature not valid`
against unfixed radarr's 8 and 8. The eviction fingerprint is gone where the fix is
deployed. It logged 0 AuthSuccess too, so that is absence-of-the-failure-mode rather
than a session watched working -- stated as such in the handoff.
Renderer selftest gains six assertions, including a --cookie-refresh check that PARSES
the duration and requires 0 < refresh < 300s (so 2m30s/150s/2.5m all stay green while a
silent widening past the token lifetime does not). NOTE, and it is in the handoff as the
top follow-up: NOTHING IN ci/ RUNS `render.py --selftest`, so those assertions are
dev-time only. conformance/tests/l0-static/auditability-proofs-producible/check.sh:291
already drives other tools' selftests for exactly this reason and is the one-line fix.
Gate (toolbox, worktree shim with the spec repo mounted at /home-network-k3s): rc 3
UNDETERMINED. Green: spec-immutability, decisions-log, lint, l0, render-golden, l1,
approval-policy. Skipped: gitea-settings (no local token), conformance (declared).
The new L0 check PASSES on this branch -- all five apps fixed in one commit.
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).