Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5 b988da8b77 docs(agents): one workspace per agent run — the shared checkout is off-limits
gate / gate (pull_request) Failing after 1m47s
gate / conformance (pull_request) Has been skipped
Agents have been working in the operator's checkout and clobbering each other.
Nothing in the tree told them not to: worktrees appeared in CLAUDE.md only as
§3's gate workarounds, and decisions/README.md described worktree isolation as
something agents already do rather than as a rule.

New CLAUDE.md §0 states the rule, with the commands and an explicit do-not list
for the shared checkout. Placed before §1 so every existing §-reference in the
repo keeps its meaning.

Measured in the shared checkout on 2026-10-05, each row a distinct failure mode:
107 `checkout: moving` reflog entries; sitting on feat/pm-agent-issue-to-pr, not
main; two other runs' untracked trees left in it; local main two commits behind
origin/main; 42 registered worktrees of which 31 are on already-merged branches;
9 gate scripts carrying a `.claude/` exclusion because an in-tree worktree made
find-based discovery count the same manifest up to 18 times.

Corrects a claim carried in earlier briefs: `git worktree remove` is NOT blocked
by the agent sandbox. Provoked on a throwaway worktree — rc 0.

§3 reframed as the normal path and grown from two breakages to three:
spec-immutability's skip is avoidable, not merely tolerated, with the spec repo
mounted read-only in the same ENGINE shim plus -e SPEC_REPO=/home-network-k3s.

CLAUDE.md is a sensitive path, so this PR needs a non-author review or an
administrator /approve-override posted as a PR review. Not posted by me: §1
forbids an agent clearing that on its own PR.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 22:18:40 -05:00
jakman85 22c94effff Merge pull request 'feat(cilium): roll the agents when cilium-config changes (merged config was silently inert)' (#53) from feat/cilium-roll-out-on-config-change into main
gate / gate (push) Failing after 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #53
2026-10-06 02:30:31 +00:00
jakman85andClaude Opus 5 d0db63911a feat(cilium): roll the agents when cilium-config changes (merged config was silently inert)
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
A merged Cilium config change did nothing, and everything reported success.
The Hubble flow exporter (#50, merged 2026-10-04) never took effect:

  HelmRelease cilium   Ready=True, "Helm upgrade succeeded ... cilium.v6"
  cilium-config        hubble-export-file-path: stdout  + the allowlist, PRESENT
  cilium agent pods    all three still PRE-MERGE, startTime 2026-10-04T07:11Z
  exported flow lines  0, 0, 0
  Flux                 every Kustomization Ready=True

Cause: the chart's rollOutCiliumPods defaults to false, so Helm rewrites the
ConfigMap and leaves the DaemonSet's pod template untouched -- and cilium-agent
reads that ConfigMap only at STARTUP. Merged, believed delivered, green
everywhere, doing nothing. The same class of failure this repo already guards
against elsewhere: a green signal over something that never ran.

Confirmed by contrast rather than reasoned: the cgroup fix (#42) DID apply --
both the DaemonSet and the running pod mount /sys/fs/cgroup -- because it changed
the POD TEMPLATE, which rolls the DaemonSet on its own. That also explains why
the agents are dated 2026-10-04T07:11Z: that was #42's rollout, and nothing has
rolled them since. The inertness is specific to ConfigMap-only changes.

Set in all three value files to hold the [parity] contract.

Cost, and it is the point: every future Cilium config change now rolls the
agents instead of waiting for a manual restart. Strictly more honest -- it
applies when merged or fails visibly -- but config edits become disruptive by
default. maxUnavailable: 1 bounds it to one node at a time. The lease-holding
node briefly loses the Gateway, PiHole-DNS and Plex VIPs as it cycles, so merge
at a quiet moment.

Merging this IS one rollout (the ConfigMap checksum annotation changes the pod
template) -- which is what finally activates the Hubble exporter.

UNVERIFIED: unreconciled; `filePath: stdout` still unproven and only answerable
after the agents restart (check for flow lines immediately); and the rollout's
actual impact on LAN DNS is unmeasured.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 21:19:13 -05:00
jakman85andClaude Opus 5 81f627167d docs(obs): record that the gate-sidecar scrape works — four places still said it did not
gate / gate (pull_request) Successful in 1m48s
gate / conformance (pull_request) Has been skipped
PR #49 (merged 21c88b0) fixed the blocked scrape and it is verified working on prod
2026-10-05: all 6 targets up == 1, health=up, lastError='', lastScrapeDuration 0.001-0.006s
against 10.0s and "context deadline exceeded" before. oauth2_proxy_* is in the TSDB for
these namespaces for the first time (5 metric names, 148 series).

Four places in the tree still asserted the pre-fix state as current, in the present tense,
in the files a reader consults first:

  platform/oauth2-proxy/podmonitor.yaml  "READ THIS BEFORE BELIEVING THE TARGETS ARE UP:
    THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY" and "SO THE TARGETS BELOW WILL READ
    up == 0 UNTIL AN INGRESS ALLOWANCE LANDS". The first actively instructs the reader to
    disbelieve a correct up == 1. Now a STATUS block with the post-fix numbers, with the
    diagnosis kept below it under an explicit HISTORY heading because it is why the fix is
    shaped the way it is.
  catalog/render.py:159  "THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED".
  catalog/render.py:1365  the same claim on the sidecar arg.
  platform/oauth2-proxy/README.md  its subsections are dated and so are honest records, but
    a reader hits the stale body long before any correction. A SUPERSEDED pointer now sits
    under the heading and an Update 2026-10-05 section carries the before/after.

The "not scraped" claims are corrected; the "a flag alone is not enough" warning is KEPT
deliberately, because that part is still true and "flag without ingress allowance" is
exactly what caused the 63.7h outage.

Two substantive errors fixed while there, not just tense. The README's "three things are
needed" listed a Service port and a ServiceMonitor — wrong, a PodMonitor targets pod ports
directly, which is why no Service exposes 44180 and the renderer needed no Service change;
following it would have sent someone to edit Service rendering for nothing. And
podmonitor.yaml deferred the fix to "the catalog/renderer, in flight on another branch" —
no such branch existed; it now names r_monitoring_ingress_policy, with a new CRAFTY section
explaining the one namespace this file cannot help.

Comments only in render.py, so the rendered output is byte-identical: no apps/ file and no
golden changes, and therefore no sensitive path and no human approval needed — the opposite
of the PR this documents.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 21:18:33 -05:00
jakman85 4386d1453b Merge pull request 'fix(plex): advertise a resolver-independent plex.direct name again (LAN-wide outage)' (#52) from fix/plex-resolver-independent-advertise into main
gate / gate (push) Successful in 1m48s
gate / conformance (push) Has been skipped
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
Reviewed-on: #52
2026-10-06 02:06:00 +00:00
jakman85andClaude Opus 5 874c6703f0 fix(plex): advertise a resolver-independent plex.direct name again (LAN-wide outage)
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
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]>
2026-10-05 20:58:55 -05:00
jakman85 bc7095677b Merge pull request 'fix(plex): unblock the app tier — drop the Certificate that stalled apps-wave-0, advertise http' (#51) from fix/plex-unblock-app-tier into main
gate / gate (push) Successful in 1m47s
gate / conformance (push) Has been skipped
Reviewed-on: #51
2026-10-05 13:53:21 +00:00
jakman85andClaude Opus 5 a0e6ef145f fix(plex): unblock the app tier — drop the Certificate that stalled apps-wave-0, advertise http
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
I broke the app tier with #48. Two faults, both mine, both measured after merge:

1. KYVERNO BLOCKS cert-manager IN APP NAMESPACES. The temporary private-key
   Secrets (plex-vip-<random>) were denied by
   secret-provenance/secrets-must-be-flux-managed, which matches every Secret in
   a namespace labelled homelab.keithjackson.dev/app-namespace=true and excludes
   only those labelled secret-managed-by=controller or carrying a Flux label.
   `plex` HAS that label; `gateway` -- where the working wildcard cert lives --
   does NOT. That asymmetry is why the existing Certificate was never affected
   and mine was, and I did not check it. secretTemplate cannot fix it: those
   Secrets come from the keymanager controller, not the Certificate template. So
   no CertificateRequest, Order or Challenge was ever created.

2. FLUX HEALTH-CHECKS THE CERTIFICATE. `optional: true` did its job -- the pod
   rolled and ran fine without the Secret -- but apps-wave-0 is wait:true, so
   Flux waits on the Certificate OBJECT:
     timeout waiting for: [Certificate/plex/plex-vip status: 'InProgress']
   apps-wave-0 never went Ready; wave-1/-2 and everything downstream of
   platform-config sat in DependencyNotReady behind it. I guarded the pod and
   missed the object. That distinction is the whole bug.

Third, independent: the advertised URL was https with no servable certificate --
Plex answers 32400 with its *.plex.direct cert, which cannot cover plex-vip --
the exact failure mode of the outage earlier the same day.

So: delete the Certificate (pruning it clears the health gate) and advertise
http://plex-vip:32400, which is MEASURED to work today -- PiHole answers the
name with the Plex VIP (verified against pihole-0) and Plex serves 32400 there.
http is explicitly temporary and only tolerable because remote access does not
work yet; the restore condition is written at the call site.

DELETED, not merely unregistered: my first attempt kept the file as "tracked but
not onboarded" and the gate rejected it -- tls-lifecycle-automated (R-TLS-02)
fails any Certificate no Flux path reconciles. Legal for a Deployment, not for a
TLS object. The re-enable recipe (Kyverno exemption + a wait:false Kustomization
+ the https flip, as ONE change) is recorded in catalog/plex.yaml.

Kept and verified working from #48: the hostname design, PiHole's split-horizon
answer, and the postStart hook -- LanNetworksBandwidth is now set live.

Gate: rc 3, all substantive stages pass (first attempt was rc 1 BLOCKED).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 17:10:25 -05:00
jakman85 c163deaef9 Merge pull request 'feat(plex): one advertised hostname for local+remote, its own LE cert, and a LAN definition so LAN clients stop reading as remote' (#48) from feat/plex-vip-hostname-and-lan-networks into main
gate / gate (push) Successful in 1m48s
gate / conformance (push) Has been skipped
Reviewed-on: #48
Reviewed-by: Keith Jackson <[email protected]>
2026-10-04 21:47:32 +00:00
jakman85 642a7752d8 Merge pull request 'feat(cilium): export Plex's pre-SNAT client address via Hubble, scoped to tcp/32400' (#50) from feat/hubble-flow-export-plex into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #50
2026-10-04 21:47:20 +00:00
jakman85andClaude Opus 5 989649e64a feat(cilium): export Plex's pre-SNAT client address via Hubble, scoped to tcp/32400
gate / gate (pull_request) Successful in 1m49s
gate / conformance (pull_request) Has been skipped
The requirement is log visibility only -- which address connected to Plex.

Plex's own log cannot show it. Every LoadBalancer Service here is
externalTrafficPolicy: Cluster, so the node holding the VIP's L2 lease SNATs
the client before forwarding to the backend pod. Measured: Plex had logged
3,214 requests from 10.0.1.x (lease holder k3s-prod-3's own address, Cilium
identity 1 = reserved:host) and ZERO from any 192.168.x address, ever. No
X-Forwarded-For either -- it serves raw tcp/32400, not HTTP via the Gateway.
Nothing on the router does this; there is no hairpin NAT. It is Cilium.

The address is not lost, only unrecorded. Cilium observes the flow at ingress,
BEFORE its own translation, so Hubble already has it:

  192.168.2.133 -> 10.0.0.135   real LAN client -> the Plex pod
  10.0.1.78     -> 10.0.0.135   same traffic post-SNAT, what Plex sees

What was missing is RETENTION: 4095/4095 flows at ~303/s is about thirteen
seconds of history, metrics disabled, no exporter configured.

So enable the static exporter, port-scoped, to the agent's stdout -- Alloy
already tails every pod into Loki, so this needs no new mount, no rotation
handling and no Alloy change.

  {namespace="kube-system", app="cilium"} |= "32400"

Scoped BY PORT, not namespace, on evidence: --to-namespace plex returned only
the post-SNAT flows; the record carrying the real client address did not match
it. A namespace filter would drop exactly what this exists to capture.

Rejected, with reasons: eTP:Local is incompatible with cilium 1.20's L2
announcer per upstream docs for this version ("may cause service IPs to be
announced on nodes without pods causing traffic drops") -- and the VIP is on
prod-3 while the pod is on prod-1, so it would black-hole 32400 immediately;
making it safe costs Plex its ability to reschedule. DSR: routing-mode is
vxlan, not native/geneve, and DSR returns bypass the ingress node, breaking
the router's stateful NAT on the remote path.

Also corrects the repo's own record: 2026-10-03 says the SNAT lands in
192.168.0.0/16 (the node's LAN address). It is 10.0.1.x, the cilium_host
inside the pod CIDR -- which is why LanNetworksBandwidth can never separate
local from remote while eTP is Cluster.

UNVERIFIED and stated in the handoff: nothing is reconciled; `filePath: stdout`
is taken from a verbatim upstream sentence but not confirmed against 1.20.1
source (a wrong notation is a silent no-op, with the file+Alloy fallback
documented); the port FlowFilter has no upstream example; and this change
ROLLS CILIUM -- prod-3 announces the Gateway, PiHole DNS and Plex VIPs, so a
lease move during the rollout could briefly interrupt LAN DNS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 16:36:12 -05:00
jakman85 21c88b0dc7 Merge pull request 'fix(obs): open 44180 to ns monitoring so the forward-auth gate sidecars are actually scrapable' (#49) from fix/oauth2-sidecar-metrics-scrape-netpol into main
gate / gate (push) Successful in 1m47s
gate / conformance (push) Has been skipped
Reviewed-on: #49
2026-10-04 21:33:28 +00:00
jakman85andClaude Opus 5 b156ee6cfa fix(obs): open 44180 to ns monitoring so the forward-auth gate sidecars are actually scrapable
gate / gate (pull_request) Successful in 1m51s
gate / conformance (pull_request) Has been skipped
platform/oauth2-proxy/podmonitor.yaml landed 2026-10-02 and discovered six targets that
have never once scraped. Measured on prod 2026-10-04: all six read health=down with
lastError "context deadline exceeded" and lastScrapeDuration exactly 10.0s — the
configured scrapeTimeout, i.e. packets DROPPED rather than refused.

Cause: every app namespace ships a default-deny NetworkPolicy with
policyTypes: [Ingress, Egress] and NO ingress key at all (deny-all-ingress), and the only
ingress allowance is <app>-allow-gateway-ingress: fromEntities: [ingress] -> 4180.
Prometheus matched no clause. Verified identical in all six of jackett, radarr, sonarr,
qbittorrent, pihole, crafty; zero namespaces had any -allow-monitoring-scrape object.

render.py already had the right idiom — r_monitoring_ingress_policy — but it returned None
unless the service declared observability.metricsEndpoint, and NO ServiceDefinition in this
catalog declares one. It was wired in at the call site and had never emitted a single
object on any branch: correct, and entirely latent.

It now also fires for the forward-auth gate sidecar's own metrics port, keyed on
_needs_oauth2_sidecar — deliberately the same condition the scrape keys on, since
podmonitor.yaml selects pods purely by the auth-mode label, so a target exists iff the
sidecar exists. Keying on observability.metrics instead would let metrics: false produce a
discovered-but-blocked target: the same "two halves authored apart" failure this function
exists to prevent.

Rendered here rather than in platform/oauth2-proxy because that tier would have to name app
namespaces created by the sibling apps-wave-0/1 tiers — it would apply on a running cluster
and fail on a cold bootstrap. podmonitor.yaml's header records that objection; rendering
per-app dissolves it.

Five CNPs result, one per forward-auth app, each opening only 44180 to only ns monitoring.
apps/ re-rendered with the invocation it was generated by (--no-image-automation, verified
byte-identical beforehand); --emit-waves deliberately not run, so no wave kustomization moved.

Three L1 goldens are regenerated with it (+63 lines, -0): a renderer change that adds an
object necessarily drifts the goldens that pin the renderer's output. The first gate run
caught container-workload-render and egress-class-render as FAIL with exactly this CNP as
the drift. default-deny-baseline-render's golden regenerated byte-identical, which is the
evidence that the regen is faithful and not a rubber stamp.

That puts conformance/** in the diff, which is a sensitive path: this PR needs a non-author
approving review. The local gate cannot see that — offline it reports approval-policy green
(N/A).

Local toolbox gate: rc 3 UNDETERMINED, not 0. Every stage that ran is green and l1 went
FAIL -> pass with the goldens; the only not-run stage is gitea-settings (no
GITEA_SETTINGS_TOKEN). L2/L3 conformance is UNVERIFIED for all 8 touched requirements.

Not verified: that the scrape now succeeds. Nothing is applied until Flux reconciles main.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 16:05:44 -05:00
jakman85andClaude Opus 5 0aa1a0a78a feat(plex): one advertised hostname for local+remote, its own LE cert, and a LAN definition
gate / gate (pull_request) Successful in 1m49s
gate / conformance (pull_request) Has been skipped
Two symptoms after the publishing fix landed:

1. LAN clients were reported as Remote. Measured root cause:
   LanNetworksBandwidth was ABSENT, so Plex had no notion of a local
   network and classified everything WAN -- 468 requests logged
   "Allowed Network (WAN)", ZERO "(LAN)", including Seerr from inside the
   cluster. allowedNetworks only waives AUTH; it does not make a client
   local. Two settings, and only one has an env hook.
2. The advertised URL hard-coded both the VIP and the server's cert hash
   into a cluster-var -- two values nothing kept in sync.

Operator design: ONE hostname, plex-vip.${BASE_DOMAIN}, split-horizon --
PiHole answers it with ${PLEX_L4_VIP} on the LAN, Cloudflare answers it
publicly with the WAN address NATed to the same VIP.

- catalog/plex.yaml: ADVERTISE_IP -> https://plex-vip.${BASE_DOMAIN}:32400,
  plus a level-triggered postStart hook (pihole/qbittorrent idiom) that
  re-asserts LanNetworksBandwidth and the three customCertificate* prefs
  every start, converting PEM -> PKCS#12 so cert renewals need no extra part.
- apps/wave-0/plex-cert.yaml: cert-manager Certificate for exactly that
  name from letsencrypt-prod, so the advertised https URL is servable.
- schema 1.11.0: `optional` on secretKeyRef/configMapKeyRef. Load-bearing --
  the cert is issued INTO the plex namespace, which the rendered plex.yaml
  itself creates, so it lands in the same reconcile; hard refs would have
  held Plex in CreateContainerConfigError and, since apps-wave-0 is
  wait:true, stalled the whole app tier on a slow DNS01 challenge.
- catalog/pihole.yaml: the plex-vip split-horizon answer. Required --
  measured, the name resolved to the Gateway VIP where nothing listens
  on 32400 (http=000).
- PLEX_ADVERTISE_URL deleted; the hostname removes the cert-hash fragility.

Corrects my own earlier claim that a SAN match is REQUIRED: it is not
(httpsRequired=0, secureConnections defaults to Preferred, so clients can
fall back to cleartext). A matching cert keeps the remote leg ENCRYPTED.
Hence: obtain a real cert for the advertised name rather than pick the name
to suit Plex's built-in one.

UNVERIFIED, and stated plainly in the handoff: nothing here has run. Cert
issuance for this name is inferred from the wildcard's success, and whether
Plex accepts an OpenSSL-3 PKCS#12 bundle is untested -- the largest risk.

Touches clusters/** -> sensitive paths -> needs a non-author approval or the
operator's /approve-override; deliberately not self-posted (CLAUDE.md 1).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 03:37:57 -05:00
jakman85 422834d4e2 Merge pull request 'fix(plex): advertise the plex.direct name, not the bare VIP (server vanished from the plex.tv account)' (#47) from fix/plex-advertise-plexdirect into main
gate / gate (push) Successful in 1m46s
gate / conformance (push) Has been skipped
Reviewed-on: #47
Reviewed-by: Keith Jackson <[email protected]>
2026-10-04 07:59:14 +00:00
jakman85andClaude Opus 5 1f4552d409 fix(plex): advertise the plex.direct name, not the bare VIP (server vanished from the plex.tv account)
gate / gate (pull_request) Successful in 1m48s
gate / conformance (pull_request) Has been skipped
customConnections held two hand-typed URLs and neither could ever complete, so
Plex had nothing publishable and the server disappeared from the account:

  - https://192.168.2.24:32400 -- the server's cert is
    *.130072f20c334d6c8f63d0dfef87309b.plex.direct with NO IP SAN, so bare-IP
    https fails TLS for every client. Measured: a continuous storm of
    "CERT: incomplete TLS handshake ... certificate unknown" from real LAN
    clients arriving through the plex-l4-tcp VIP.
  - https://plex.home.keithjackson.dev:32400 -- nothing listens; the Gateway
    serves that host on 443 (measured http=000 against 192.168.2.20:32400).

With no completable connection plex.tv published nothing ("Mapped - Not
Published"), Plex fell back to Relay, and clients reported "not authorized".

Fix: advertise <dashed-ip>.<serverhash>.plex.direct -- public DNS that resolves
to the embedded address, so it points at the LAN VIP and matches the wildcard
cert. Verified with full TLS validation (no curl -k): 200 + verify OK, where
the bare-IP form fails to connect.

Declared as ADVERTISE_IP so git owns the value instead of a web form: the
image's 40-plex-first-run writes it into customConnections on every pod start,
outside the claim guard.

Does NOT fix off-LAN access -- the advertised address is RFC1918 and the WAN
tcp/32400 forward remains unverified. ManualPortMappingMode=1 with an absent
port is left as-is (no GitOps path exists for it).

Touches clusters/** -> sensitive paths -> needs the operator's
/approve-override, deliberately not self-posted (CLAUDE.md 1).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 02:53:01 -05:00
jakman85 c95b1c7121 Merge pull request 'fix(cilium): point cgroup.hostRoot at the host cgroup2 root (socket-LB never covered host processes)' (#42) from fix/cilium-cgroup-hostroot into main
gate / gate (push) Failing after 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #42
2026-10-04 07:10:31 +00:00
jakman85 ed6afc3d3f Merge pull request 'docs(decisions): record that PiHole HA is verified live, failover drill included' (#46) from docs/pihole-ha-verified into main
gate / gate (push) Successful in 1m46s
gate / conformance (push) Has been skipped
Reviewed-on: #46
2026-10-04 07:08:19 +00:00
jakman85andClaude Opus 5 254985baa6 docs(decisions): record that PiHole HA is verified live, failover drill included
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
No manifests change. The three entries merged earlier today each say, correctly
at the time, that nothing had been reconciled -- which is now stale in exactly
the way CLAUDE.md section 2b warns about: a reader would conclude the work is
unproven. This entry is the current state and does not edit the earlier files.

Measured on live prod after dce20b7: apps-wave-0 reconciled in ~90s (inside the
5m timeout the multi-replica entry flagged as a risk); pihole-0 on k3s-prod-3
and pihole-1 on k3s-prod-2, so the required anti-affinity was satisfied; both
per-replica claims Bound; the old Deployment and the old unused PVC both pruned;
PDB healthy with one disruption allowed.

The mountPath fix is confirmed: /etc/pihole is now on /dev/longhorn with 6.9M
used on both pods, where it was previously on the overlay.

Phase 1's declarations reproduce onto a FRESH volume, which was the whole reason
for sequencing it first: 72,234 gravity domains rebuilt on each replica from the
declared adlist, and the declared SOPS secret authenticates on BOTH pods
("valid":true via each container's own env, value never printed).

FAILOVER DRILL, observed by the operator: a replica was deleted, recreated in
~30 seconds, and DNS never dropped a request. First direct evidence the replica
count delivers what it was added for.

Re-deriving the SLI from the rendered workload kind mattered concretely: the
StatefulSet series returns 2 while the old Deployment series now returns ZERO
series, so a stale Deployment-shaped rule would have matched its own absent()
branch and fired permanently -- a standing false page.

Still unverified and stated as such: node failure (the drill deleted a pod, so
the 300s toleration path is untouched), DNS during the cutover itself, two-node
loss, and the cluster-wide dependency that replicas cannot address.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 02:03:42 -05:00
jakman85 dce20b7be6 Merge pull request 'feat(pihole): two replicas on different nodes, and fix a PVC mounted where pihole never writes' (#45) from feat/pihole-multi-replica into main
gate / gate (push) Successful in 1m47s
gate / conformance (push) Has been skipped
Reviewed-on: #45
2026-10-04 06:50:56 +00:00
jakman85andClaude Opus 5 028853c87c feat(pihole): two replicas on different nodes, and fix a PVC mounted where pihole never writes
gate / gate (pull_request) Successful in 1m47s
gate / conformance (pull_request) Has been skipped
Phase 2 of making LAN DNS survive a node failure. Pihole was one pod on one
node; measured 2026-10-04, losing that node meant roughly 6-10 minutes with no
LAN resolution (tolerationSeconds 300 + NotReady detection + a Longhorn RWO
force-detach), while losing the node holding the pihole-dns L2 lease -- a
DIFFERENT node -- meant tens of seconds. Two of three nodes were single points
of failure with wildly different blast radii.

Adds `availability.replicas` to the ServiceDefinition schema (1.10.0). Intent
only; the mechanism is render.py's, documented in the renderer contract. 1 or
absent renders a Deployment exactly as before -- verified byte-identical across
the whole apps/ tree. >1 renders a StatefulSet (a longhorn PVC is RWO, so a
multi-replica Deployment never schedules the second pod), per-replica
volumeClaimTemplates, REQUIRED hostname anti-affinity, podManagementPolicy
Parallel, a minAvailable:1 PodDisruptionBudget, and an SLI that follows the
rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever).

Pinned by ~20 new --selftest assertions rather than a conformance change.

ALSO FIXES A LIVE BUG. Pihole's config PVC was mounted at the renderer default
/data/config, but pihole writes to /etc/pihole. Measured: /etc/pihole (7.1M --
gravity.db, pihole.toml, query DB) was on the OVERLAY, while the 2Gi Longhorn
volume held lost+found and 24K of nothing. So every restart discarded all state
and re-downloaded 72k gravity domains, and backup.policy has been backing up an
empty volume while reporting success. Fixed here because otherwise the new
per-replica volumes would hold nothing while state stayed ephemeral -- and
because persisting gravity.db is what makes a restart cheap.

The /etc/pihole mount is safe on the image's own evidence: start.sh is PID 1 as
uid 0 and chowns the directory recursively (no fsGroup needed), and it calls
migrate_gravity which creates the database when absent.

The gate caught a real regression on the first run: the wave-0 alert unit test
fed pihole a Deployment-shaped series. Updated; promtool SUCCESS.

Touches no sensitive paths, so this one needs no override.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 01:34:52 -05:00
jakman85 17f0c11403 Merge pull request 'feat(pihole): declare the admin password and adlists in git' (#44) from feat/pihole-declare-state into main
gate / gate (push) Successful in 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #44
Reviewed-by: Keith Jackson <[email protected]>
2026-10-04 06:14:47 +00:00
jakman85andClaude Opus 5 3b1d6ae115 feat(pihole): declare the admin password and adlists in git
gate / gate (pull_request) Successful in 1m47s
gate / conformance (pull_request) Has been skipped
Prerequisite for running more than one PiHole replica. A second replica with a
fresh PVC would have come up serving DNS and blocking nothing, with a different
admin password -- answers varying by which pod replied.

pihole.toml's own ### CHANGED markers gave the exact inventory: four settings
already came from env (declared), one came from the image, and exactly ONE
operator-set value lived only on the PVC -- webserver.api.pwhash, a one-way hash
nobody could recover. Separately the adlists were undeclared: one list and 72,234
gravity domains that nothing in this repo named.

Declares both:
- A SOPS secret feeding FTLCONF_webserver_api_password. webserver.api.password
  takes cleartext and FTL hashes it itself (verified: pihole setpassword IS
  setFTLConfigValue on that key), so the PVC's pwhash becomes derived state.
- A level-triggered postStart hook that reconciles the declared adlist set into
  gravity.db -- adds, re-enables, and disables (never deletes) undeclared lists,
  running pihole -g only when something moved. A file would not work: v6's
  adlists.list import is migration-only, guarded on the DB not existing.

The rendered hook was run in the live pod against throwaway copies of gravity.db,
exercising all four paths. A fresh DB built from the image's own schema template
has ZERO adlists, confirming this is load-bearing rather than cosmetic.

Password kept rather than set empty: the pihole Service still publishes port 80
past the oauth2-proxy, closed only by policy, and default-deny has drifted here
before.

Multi-replica is phase 2 and deliberately not in this branch -- it needs a schema
field, a StatefulSet (the config PVC is RWO), anti-affinity and a PDB.

Adds a *.sops.yaml -- a sensitive path -- so approval-policy will block pending
an operator /approve-override.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 01:07:30 -05:00
jakman85 939461120a Merge pull request 'feat(plex): expose tcp/32400 for remote access via a pinned LoadBalancer VIP' (#43) from feat/plex-remote-access-l4 into main
gate / gate (push) Failing after 1m56s
gate / conformance (push) Has been skipped
Reviewed-on: #43
Reviewed-by: Keith Jackson <[email protected]>
2026-10-04 04:46:34 +00:00
jakman85andClaude Opus 5 fbaeb3e038 feat(plex): expose tcp/32400 for remote access via a pinned LoadBalancer VIP
gate / gate (pull_request) Successful in 1m49s
gate / conformance (pull_request) Has been skipped
Remote Plex clients had no path in, for two measured reasons: Plex advertised
no remote connection at all (customConnections and ManualPortMappingMode both
ABSENT while PublishServerOnPlexOnlineKey=1), and its Service was ClusterIP, so
32400 had no off-cluster path. The L7 Gateway was NOT the problem -- it serves
Plex correctly (200, claimed=1, 401 on protected endpoints) -- but Plex clients
discover servers through plex.tv, not DNS, so an HTTPRoute cannot put this
server on a client's connection list.

Operator chose the native path over ADVERTISE_IP: Plex terminates its own TLS
with *.plex.direct and streams bypass the Envoy L7 hop.

Adds a hand-written non-catalog unit (the SD schema cannot express a
LoadBalancer Service or a pinned lbipam VIP), registered in render.py's
EXTRA_UNITS so --emit-waves places it in plex's own wave.

externalTrafficPolicy: Cluster, inheriting crafty's 8-day-outage lesson rather
than re-deriving it. Known consequence, documented: SNAT puts remote clients
inside ALLOWED_NETWORKS, so Plex meters them as LAN. Not an auth change.

Remote access does NOT work until the operator does two manual steps that
cannot live in this repo -- the WAN tcp/32400 forward and Plex's "manually
specify public port" toggle (no env var exists for it; verified NONE in the
image's init surface). runbooks/plex-remote-access.md is the procedure.

Touches clusters/** -- a sensitive path -- so the approval-policy stage will
block pending an operator /approve-override.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-03 23:30:46 -05:00
jakman85andClaude Opus 5.5 9a69f6906a fix(cilium): point cgroup.hostRoot at the host's real cgroup2 root
gate / gate (pull_request) Successful in 1m47s
gate / conformance (pull_request) Has been skipped
hostRoot was /run/cilium/cgroupv2 with autoMount disabled, but nothing
mounts cgroup2 there. The agent mounted one from inside its own private
cgroup namespace, so socket-LB attached to the agent's own cgroup and
host processes got no Service/LB translation. k3s-prod-1 (Gateway L2
lease holder + kube-vip VIP) therefore could not reach the Gateway VIP,
and its apiserver's OIDC authenticator has failed since 2026-09-12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 18:07:18 -05:00
jakman85 7ef2af205d Merge pull request 'fix(node-baseline): pin the OIDC issuer to the Gateway VIP on prod nodes' (#41) from fix/node-keycloak-hosts-pin into main
gate / gate (push) Successful in 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #41
2026-10-03 22:46:38 +00:00
jakman85andClaude Opus 5.5 788eaeae62 fix(node-baseline): pin the OIDC issuer to the Gateway VIP on prod nodes
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
After public DNS moved to the WAN IP (#40), prod nodes resolve
keycloak.home.keithjackson.dev to a WAN address the router does not
hairpin, so the apiserver could not reach its OIDC issuer. Adds an opt-in
node_baseline_hosts_pins /etc/hosts block; prod pins keycloak to
GATEWAY_VIP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 17:39:37 -05:00
jakman85 576e1abb19 Merge pull request 'feat(dns): publish Gateway names to the WAN via in-cluster DDNS' (#40) from feat/public-dns-wan-target into main
gate / gate (push) Successful in 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #40
2026-10-03 22:25:13 +00:00
jakman85andClaude Opus 5.5 a1e808e697 feat(dns): publish Gateway names to the WAN via in-cluster DDNS
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
Gateway-published records now CNAME to wan.${BASE_DOMAIN}, which a
favonia/cloudflare-ddns Deployment keeps at the home WAN's current public
IP. Operator decision: every Gateway-published name goes public; internal
services (controller: none) remain unpublished.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 16:59:53 -05:00
jakman85 bcb8931b4f Merge pull request 'fix(runbooks): Plex hw transcoding WORKS — the dashboard was never the proof' (#39) from fix/plex-quicksync-verification into main
gate / gate (push) Successful in 1m44s
gate / conformance (push) Has been skipped
Reviewed-on: #39
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 06:43:04 +00:00
jakman85 0d7f94098b Merge branch 'main' into fix/plex-quicksync-verification
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
2026-10-02 06:37:04 +00:00
jakman85 5ffd0c98bc Merge pull request 'fix(ci): let an administrator merge their own green PR — required_approvals 1 → 0, plus an attributed /approve-override' (#38) from fix/admin-approval-override into main
gate / gate (push) Successful in 1m44s
gate / conformance (push) Has been skipped
Reviewed-on: #38
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 06:17:17 +00:00
jakman85andClaude Opus 5 f0994c11a0 docs(decisions): record the PR's measured CI result — gitea-settings has drifted
gate / gate (pull_request) Failing after 1m44s
gate / conformance (pull_request) Has been skipped
Read PR #39's own gate log rather than the tick (CLAUDE.md §2b). Every stage
this branch could affect is green: decisions-log, lint, l0, render-golden, l1,
and approval-policy (which evaluated for real on the pull_request event and
passed — no sensitive path touched). The one failure is `gitea-settings`:

    required_approvals: DECLARED 1 but LIVE is 0
    Gitea has DRIFTED from infra/gitea/expected-branch-protection.json

Every other declared setting matched, and one runner is online. So live `main`
currently requires ZERO approvals while the declaration says 1, and because
.gitea/workflows/gate.yml sets REQUIRE_GITEA_SETTINGS=true, that drift blocks
EVERY PR in the repo, not only this one. The check that would notice approvals
are unenforced is the check that is failing.

Not touched here: infra/gitea/** is a sensitive path (non-author approval
required) and outside this branch's documentation-only scope. Re-asserting the
protection or changing the declaration is an operator decision in its own PR;
local branch fix/admin-approval-override (616a497) looks like that work in
flight.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-02 01:01:13 -05:00
jakman85andClaude Opus 5 b52c8d1af6 fix(ci): accept the override from a PR review, and stop misdiagnosing an unreadable comments API
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
The override did not work on its first real test, and the reason was invisible
from the outside. Run 203 on PR #38: a well-formed `/approve-override` posted by
jakman85 in the PR comment box, 29 minutes newer than the head commit, and the
stage still blocked with:

    approval-override: COMMENTS_JSON is not a list

SETTINGS_API_TOKEN is minted [read:repository, read:admin], and Gitea puts
/issues/{n}/comments behind the *issue* scope — so Gitea answered with an error
OBJECT, `jq -e .` accepted it as valid JSON, and the stage fell through to 'no
approving review'. A permission failure wearing the costume of a missing
override. /pulls/{n}/files and /pulls/{n}/reviews are under read:repository,
which is why every other part of the same stage worked.

1. The diagnosis no longer lies. COMMENTS_JSON leaves the must-parse-or-block
   loop and is checked for being an ARRAY; when it is not, the stage prints
   Gitea's own .message, names the missing scope and says where to post instead.
   An unreadable source can only cause a false BLOCK, never a false pass, so a
   loud warning is the honest behaviour and blocking was not.
2. An override is accepted from a PR REVIEW body as well as a comment, so the
   hatch is not hostage to one API scope — a review of type 'Comment' works on
   the token CI has today. The gate names which source it used; PENDING
   (unsubmitted, author-only) reviews are dropped.
3. set-ci-token.sh requests read:issue, making the ordinary comment box work —
   on a RE-RUN only, since an existing token's scopes cannot be widened.

ci/tests/approval-override.test.sh: 23 -> 38 assertions, covering the reviews
source, PENDING reviews, an error object from the comments API, 'an unreadable
comments source must not suppress a valid review override', latest-wins across
both sources, and that the diagnostic quotes Gitea's message.

Also measured and recorded: run 203 shows gitea-settings PASS after the operator
ran the live PATCH, with block_admin_merge_override still true — the narrow
change held, the approval count moved and the gate's teeth did not.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-02 00:50:20 -05:00
jakman85andClaude Opus 5 1677dff568 fix(runbooks): Plex hw transcoding WORKS — the dashboard was never the proof
gate / gate (pull_request) Failing after 1m46s
gate / conformance (pull_request) Has been skipped
Hardware transcoding is confirmed working on prod (2026-10-02): VAAPI for both
decode and encode on /dev/dri/renderD128, with zero-copy, measured in
`Plex Media Server.log` in the running pod. The device-plugin mechanism from #37
works end to end.

`runbooks/plex-quicksync.md` said the Plex dashboard showing `Transcode (hw)` was
"the *only* proof available, and nothing else will ever tell you". That is false:
Dashboard -> Activity showed nothing while the feature was fully working, so the
page's authoritative check was silent on a healthy system and sent the operator
hunting a fault that did not exist.

Three inference errors corrected, two kept as named false leads:

1. The LOG is the primary check, not the dashboard. The grep ships in its
   REDACTING form -- that log carries API URLs with `X-Plex-Token` (CLAUDE.md §6).
   Also recorded: `Plex Transcoder Statistics.log` is the WRONG file (XML
   <Variant/> records, no ffmpeg output), and why `grep -a` is required. The
   dashboard is kept but demoted to best-effort, with its silence stated to prove
   nothing.
2. An fd snapshot is not evidence. A /proc/*/fd sweep found no holder of
   renderD128 while hardware transcoding was active -- sessions are short and the
   scan landed in a gap. The reliable process-level check is the transcoder's
   argv (-hwaccel, -hwaccel_device, vaapi, renderD128).
3. A missing preference key means nothing. `HardwareAcceleratedCodecs` was absent
   from all 27 attributes with the toggle on and working, while
   `HardwareDevicePath` was written and the log said "hardware transcoding:
   enabled". Only `="0"` is conclusive; the log settles it.

Also stale and now fixed: GID 992 is a hostPath-era number. Under the plugin the
injected device is root:root, so the image's hook resolves GID 0 and adds `plex`
to the root group (`Groups: 0 100 1000`, `groups=1000(plex),0(root),100(users)`).
Marked as an inherent property of the plugin's DeviceSpec, not a defect, and
explicitly not a reason to reintroduce supplementalGroups (still a no-op --
s6-setuidgid rebuilds the group list from /etc/group). Added a per-title fallback
entry (UHD 630 has no AV1 decode), labelled a HYPOTHESIS: the title that fell
back was never checked. `runbooks/README.md`'s index entry was stale the same two
ways and is corrected.

Unchanged on purpose: the "STOP HERE IF PLEX IS Pending" section, the escape
hatch, and the no-alert-is-possible reasoning (no metricsEndpoint, no
ServiceMonitor, nothing a rule could evaluate).

Documentation only -- no catalog/, schemas/, apps/ or platform/ change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-02 00:49:53 -05:00
jakman85andClaude Opus 5 8ccfdab180 fix(ci): let an administrator merge their own green PR
`required_approvals: 1` on `main` was a lock-out, not a protection. Gitea
refuses to let a PR's author approve their own PR, and `jakman85` is the only
collaborator — so no account in existence could approve a PR the operator
opened, including the PR that would fix that. Measured 2026-10-01 on PR #35:
gate green in 1m41s, reviews `[]`, `required_approvals: 1`, unmergeable.

It was never the documented design either: `ci/approval-policy.sh`'s header and
`ci/gate.sh:194` both already describe the model as "required_approvals drops
to 0 and the GATE decides", and `require-gate.sh`'s own closing note named this
failure and prescribed this remedy. 2026-09-30's entry flagged the 1-vs-0
disagreement as unresolved; this settles it at 0.

Layer 1 — branch protection. required_approvals 1 -> 0 in the declaration, plus
infra/gitea/allow-admin-self-merge.sh to apply it live (target read FROM the
declaration, so the script cannot drift from what the gate checks).
require-gate.sh moves to 0 too; left at 1 it would re-impose the lock-out on its
next run. The narrow choice is deliberate: the other way to unblock an owner is
block_admin_merge_override: false, which would re-open what
block-admin-override.sh was written to close (PRs #5-#9, zero approvals). This
drops the approval COUNT only — block_admin_merge_override and
enable_status_check stay true and are asserted after the PATCH, so a red gate
stays unmergeable by everyone, administrators included.

Layer 2 — ci/approval-policy.sh. Sensitive paths still need a non-author
approval the sole admin cannot get, so an administrator may waive it with an
`/approve-override <reason>` comment: directive must start a line, reason
mandatory, permission re-checked LIVE, invalidated by a new commit, and the
overrider + reason printed in the gate log and job summary. Candidate selection
is the pure, offline ci/approval-override.py (23 unit assertions, all passing);
authorisation stays in the fail-closed stage. Every new failure mode blocks.

The cost, stated not buried: for an administrator, "a change to the gate cannot
be gated by the gate" is now advisory rather than structural. Unavoidable with
one human; what is bought back is an attributed, commit-scoped record instead of
a silent bypass. And the uncomfortable part, accepted as an operator decision:
authorisation is by live permission alone, and the AGENT account `gitea-admin`
also holds `owner` — so an agent can clear its own PR this way. Documented in
approval-policy.json, the stage header, and CLAUDE.md (which tells agents not
to). Narrowing needs no code change: add `override_admins` and the stage already
requires membership.

Adjacent fix, in scope because `make -C ci test` is the only runner for the
suite added here: ci/tests/gate-decision.test.sh hand-maintained a stub list
that "must name EVERY stage gate.sh runs" and omitted `approval-policy` (added
to gate.sh by PR #17), so run_stage hit exit 127 and 15 of 50 assertions failed.
Verified pre-existing on pristine origin/main @ 0c7e8dd. The list is now derived
from gate.sh's own run_stage lines, so the omission cannot recur; the suite goes
15-failed -> 50 passing and `make -C ci test` goes FAILED -> all suites passed.

Toolbox gate: rc 3 UNDETERMINED — every stage green except `gitea-settings`
(no SETTINGS_API_TOKEN locally) and `conformance` (no cluster). NOT a green run.

The live rule is UNCHANGED and still reads 1 — mutating it is an operator
action. Until allow-admin-self-merge.sh is run, ci/gitea-settings.sh will
correctly report DRIFT and this PR will be red.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-02 00:44:31 -05:00
jakman85 948875aad3 Merge pull request 'Added arr suite ability to connect to plex' (#35) from bugfix/arr-to-plex into main
gate / gate (push) Failing after 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #35
2026-10-02 05:34:35 +00:00
jakman85 bdfa72b1c7 Merge pull request 'feat(gpu): satisfy hardware.gpu: intel-quicksync with gpu.intel.com/i915 instead of a /dev/dri hostPath (the hostPath was measured not to work: device-cgroup EPERM)' (#37) from feat/quicksync-via-device-plugin into main
gate / gate (push) Successful in 1m44s
gate / conformance (push) Has been skipped
Reviewed-on: #37
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 05:02:23 +00:00
jakman85 4f16e83eec Merge pull request 'feat(obs): make oauth2-proxy auth failures visible — the gates emit nothing' (#29) from feat/oauth2-proxy-auth-failure-visible into main
gate / gate (push) Successful in 1m43s
gate / conformance (push) Has been skipped
Reviewed-on: #29
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 04:55:43 +00:00
jakman85 0c7e8dd3ba Merge pull request 'fix(ci): catalog/render.py's 106 selftest assertions never ran in the gate' (#33) from fix/render-selftest-runs-in-gate into main
gate / gate (push) Successful in 1m42s
gate / conformance (push) Has been skipped
Reviewed-on: #33
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 04:33:02 +00:00
jakman85 8c12a1e525 Merge pull request 'feat(auth): declare API paths per service, so --api-route can ship at all' (#34) from feat/oauth2-proxy-api-route-per-app into main
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Reviewed-on: #34
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 04:31:56 +00:00
jakman85 8a094c46f7 Added decision record for allowing arr apps to connect to plex
gate / gate (pull_request) Successful in 1m41s
gate / conformance (pull_request) Has been skipped
2026-10-01 23:09:08 -05:00
jakman85 58f02fe8b1 Removed trailing whitespace
gate / gate (pull_request) Successful in 1m41s
gate / conformance (pull_request) Has been skipped
2026-10-01 22:53:04 -05:00
jakman85andClaude Opus 5 2983378496 chore(decisions): record the CI reading, and why the metrics policy is NOT here
gate / gate (pull_request) Successful in 1m44s
gate / conformance (pull_request) Has been skipped
Follow-up to the branch's one substantive commit; no code change.

CI on 99560c2d, read from the commit's /statuses rather than from a tick:
  gate / gate (pull_request)         state=failure   desc='Failing after 1m44s'
  gate / conformance (pull_request)  state=pending   desc='Blocked by required conditions'
The second line is PENDING, not the `success` + "Has been skipped" trap -- the
conformance job is gated on the gate job, which failed, so it never ran.

MY LOCAL `approval-policy: green` WAS MEANINGLESS AND I NEARLY REPORTED IT AS
EVIDENCE. ci/approval-policy.sh:47 reads GITHUB_EVENT_NAME, unset outside CI, so
it short-circuits to "N/A": a clean local gate STRUCTURALLY cannot catch a
sensitive-path problem. Verified by re-running with
GITHUB_EVENT_NAME=pull_request PR_NUMBER=34, where the stage evaluates and fails
CLOSED on the missing token while every other stage stays green -- which bounds
the CI failure to that one stage. I could NOT read CI's job log to confirm it
from CI's own output: this Gitea answers 404 on /actions/runs and on
/actions/jobs/{task}/logs, so only /actions/tasks is available. Attribution
therefore rests on the local reproduction plus the sensitive-path rule, not on
CI's log -- weaker evidence than PR #28 had, named rather than papered over.

Branch protection and the runner re-measured against the live API (CLAUDE.md
2b), not inherited from the CLAUDE.md table: protected=True,
user_can_push=False, enable_status_check=True, contexts=['gate / gate
(pull_request)'], required_approvals=1; runner id=1 incluster-dind online. So a
human is required on every PR here regardless of path. I did NOT provoke a
rejected push to main, so user_can_push is the API's word only.

DECLINED, with reasons: bundling Ines Dubois's metrics-ingress NetworkPolicy.
The coordinator asked for my judgement and the answer is to queue it as the next
PR, not to drop it. Principally because it is a MODEL question, not a policy to
bolt on: schema 1.6.0's observability.metricsEndpoint already renders the
monitoring-namespace ingress allow, and what the model cannot express is a
SECOND metrics endpoint belonging to a sidecar the RENDERER injected. Keying a
bespoke policy off the pod label auth-mode=forward-auth hardcodes "forward-auth
implies 44180" instead of deriving it -- the "list, don't derive" anti-pattern
oauth2-proxy-cookie-names-distinct assertion (5) forbids, one directory from the
check that forbids it. Also: it is a trust-boundary ingress allow and a
different requirement set (R-NET-01/02, R-OBS-01 vs R-IDP-02), so it should not
be reviewed in the shadow of an --api-route headline; the conflict argument does
not apply to my diff, which regenerates TWO goldens not five, and a
CiliumNetworkPolicy is a different document in files git merges independently;
every fact in the finding is hers and I have measured none of it; and the
PodMonitor it serves is in HER PR #29, so shipping the policy alone would open
an ingress allow for a scraper that selects nothing.

Also records that the sidecars' metrics remain UNSCRAPABLE and that this branch
does not change that.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 22:37:06 -05:00
jakman85 9048c2f5e8 Forgot to render the golden from the catalog -- fixed
gate / gate (pull_request) Failing after 1m42s
gate / conformance (pull_request) Has been skipped
2026-10-01 22:35:47 -05:00
jakman85 753c48be24 Added arr suite ability to connect to plex
gate / gate (pull_request) Failing after 1m41s
gate / conformance (pull_request) Has been skipped
2026-10-01 22:26:35 -05:00
jakman85andClaude Opus 5 1e71b578d5 docs(obs): record why CI is red — approval-policy on the one conformance entry
gate / gate (pull_request) Successful in 1m41s
gate / conformance (pull_request) Has been skipped
The gate job reported failure while the local run was rc 3 with nothing failed. Measured
cause is a single sensitive path (the KNOWN_EXTERNAL_MONITORS entry) with no non-author
approving review, which ci/approval-policy.sh blocks fail-closed by design.

Also records the general trap: approval-policy short-circuits to N/A off a pull_request
event, so a clean LOCAL gate is a false reassurance for any branch touching a sensitive
path. Only the PR run tells the truth.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 22:12:19 -05:00
jakman85andClaude Opus 5 99560c2d22 feat(auth): declare API paths per service, so --api-route can ship at all
gate / gate (pull_request) Failing after 1m44s
gate / conformance (pull_request) Has been skipped
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]>
2026-10-01 22:07:08 -05:00
jakman85andClaude Opus 5 bce101d03f feat(obs): wire the gate-sidecar scrape, and fix the alert that would have gone silent doing it
gate / gate (pull_request) Failing after 1m42s
gate / conformance (pull_request) Has been skipped
PR #28 gave the five fleet oauth2-proxy sidecars --metrics-address=0.0.0.0:44180 and a
named `metrics` port. This wires a scraper to them and, in the process, found a real bug
in the alert this branch added.

PodMonitor, not ServiceMonitor. Measured: no Service exposes 44180 on any of the five
(jackett/radarr/sonarr/qbittorrent/pihole all publish only their app port + authproxy
4180), so a ServiceMonitor would select nothing. A PodMonitor targets pod ports directly
and needs no change to catalog/render.py or the apps/wave-* renders and goldens.
Selector is the pod label homelab.keithjackson.dev/auth-mode=forward-auth — present on
exactly those five gate pods, absent from crafty's, which still has no metrics listener.
The property, not an app list.

THE BUG: `up{container="oauth2-proxy"}` -> `up{container="oauth2-proxy"} == 1`.

A target that is discovered but FAILING still publishes `up`, with value 0. The series
exists, so count by (namespace) returned 1, `unless` removed the namespace, and
OAuth2ProxyGateUnobserved went QUIET while nothing was being scraped at all — this
branch's own disease reproduced inside the alert written to cure it. Not hypothetical:
the PodMonitor lands five targets whose scrape is dropped, so the bare form would have
silently cleared on this very commit. Proven by mutation: reverting the `== 1` fails the
two new unit-test scenarios (got: nil) and leaves every other assertion green.

The scrape does NOT work yet, and that is documented rather than papered over. From a
pod in ns monitoring to the jackett pod, :44180 AND :4180 both time out; the shared
proxy's :44180 answers. ns jackett's default-deny is podSelector:{} with
policyTypes:[Ingress,Egress] and NO ingress key (deny-all), and the one allowance is
fromEntities:[ingress] to 4180 — the Gateway's Envoy only. The endpoint itself is
healthy: port-forwarded, /metrics returns http=200, 10208 bytes, all three oauth2_proxy_*
families including requests_total{code="302"}. Same bug and fingerprint as
platform/longhorn/networkpolicy-metrics.yaml records for Longhorn; the fix is one narrow
rule per app, and it cannot ship from this tier because oauth2-proxy and apps-wave-0 are
Flux SIBLINGS (both dependsOn platform-config), so naming ns jackett here would apply
today and fail on a cold rebuild. Needs an owner in the renderer.

Touches conformance/** : one KNOWN_EXTERNAL_MONITORS entry, which is the mechanism the
failing check explicitly instructs ("DECLARE it ... with who creates it") and which
flux-podmonitor.yaml already uses. It records that the port name was measured on prod
instead of resolved from a Service, and that the targets are currently down. That makes
this PR require a non-author review on the sensitive-path rule.

Also: ratio alert still NOT shipped. oauth2_proxy_requests_total now exists on the
endpoint but not in the TSDB, so writing it today would make it inert for exactly the
reason it was refused on 2026-09-30. Runbook gains policy-vs-no-target triage and the
radarr /signalr/messages/negotiate worked case — one orphaned browser tab, 206 identical
302s, nobody ever re-prompted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 21:56:07 -05:00
jakman85 ba79a1b59b Merge remote-tracking branch 'origin/main' into feat/oauth2-proxy-auth-failure-visible
# Conflicts:
#	runbooks/kustomization.yaml
2026-10-01 21:40:37 -05:00
jakman85andClaude Opus 5 d44490736c fix(ci): catalog/render.py's 106 selftest assertions never ran in the gate
gate / gate (pull_request) Successful in 1m40s
gate / conformance (pull_request) Has been skipped
`catalog/render.py --selftest` executes 106 assertions on the renderer's output
contract. Nothing in ci/ or .gitea/ had ever invoked it — grepped and confirmed —
so all 106 were dev-time only, and the merge gate could not tell a sound renderer
from a gutted one. Recorded as the top follow-up of
decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md.

`ci/render-golden.sh` now runs it and exits 1 when it fails. One file, six lines
of invocation; the rest is the rationale comment.

WHY HERE. The lead was auditability-proofs-producible/check.sh:291, which drives
conformance/lib/{record_run,status}.py --selftest "because nothing else drives
them". That is the wrong hook: the check serves R-SEC-03 and its subject is the
evidence chain — can a forged or stale run become an audit proof? The renderer is
not in that chain, and hanging it there would make the check's name, its intent
annotation and its requirement mapping untrue. This stage's subject already IS
catalog/render.py, so the renderer's own contract test belongs in it.

The two assertions are independent, and that is the point: render-golden catches
golden-vs-renderer drift but is satisfied by ANY renderer whose output was
re-rendered — including one that just lost a contract invariant and had all ten
goldens regenerated in the same commit. That is precisely the hole the follow-up
named. The selftest catches it; render-golden cannot.

It runs before the empty-catalog exit 3 because the selftest renders synthetic
ServiceDefinitions in memory and is catalog-independent — an empty catalog must
not be able to skip it. Failure is a hard rc 1, never a skip: the renderer is
source in this repo, so once python3 and render.py are both present (asserted
above it) there is no legitimate environmental cause for "could not run".

PROVEN TO FAIL, not merely to pass. Against mutated COPIES of the renderer via
the stage's existing RENDER_PY override, so catalog/render.py — another branch's
file this session — was never touched:
  * deleting the --cookie-name emission (reverting PR #28's fleet-wide cookie
    fix) -> stage rc 1;
  * OAUTH2_COOKIE_REFRESH 2m30s -> 10m -> stage rc 1 with the named assertion
    "parses to 600.0s, which is not inside (0, 300s)" — one of the two flags the
    cookie entry recorded as UNGATED, and one render-golden's diff would miss.

AND PROVEN TO PROPAGATE, in the pinned toolbox: selftest rc 1 -> stage
render-golden FAIL -> `MERGE GATE: BLOCKED`, gate.sh rc 1. Worth provoking: this
repo flattens exit codes, and .gitea/workflows/gate.yml:138 runs
`./ci/gate.sh || [ "$?" -eq 3 ]`, so a failure degraded to a skip would be
swallowed by CI.

render.py itself is unchanged. No conformance/ path is touched, no gate stage is
added, nothing is deployed by this.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 21:21:49 -05:00
jakman85andClaude Opus 5 fc7ec01628 feat(obs): make oauth2-proxy auth failures visible — alert on the gap, not on absent metrics
gate / gate (pull_request) Successful in 1m42s
gate / conformance (pull_request) Has been skipped
Jackett failed on 2026-09-30 in the most expensive way available: HTTP 200 and a
plausible page. oauth2-proxy answers an unauthenticated XHR with a 302 to Keycloak
rather than a 401; the browser follows it silently against a live SSO session and
the call resolves 200 carrying a 44 KB HTML dashboard where the app's JavaScript
wanted JSON. The user is never re-prompted, reloading never helps, and every uptime
check stays green. `jackett-down` watches replicas_available, which was 1 throughout.

The reason nothing alerted turns out not to be a missing rule. Measured on prod:

    count by (__name__) ({__name__=~"oauth2_proxy.*"})
      oauth2_proxy_requests_in_flight => 2

Two series, both from the SHARED forward-auth Deployment. The six per-app gate
sidecars (crafty, jackett, pihole, qbittorrent, radarr, sonarr) publish nothing at
all: no --metrics-address, so the metrics listener never starts; no `metrics`
container port; no ServiceMonitor or PodMonitor selects them. The alert the incident
calls for — sustained auth redirects to an authenticated user — is therefore NOT
WRITABLE, and writing it anyway would have shipped a rule that is present,
evaluating, permanently silent and indistinguishable from health.

So alert on the condition that made the incident invisible, from families that ARE
scraped:

  OAuth2ProxyGateUnobserved
    count by (namespace) (kube_pod_container_info{container="oauth2-proxy"})
      unless
    count by (namespace) (up{container="oauth2-proxy"})

kube_pod_container_info is kube-state-metrics (a declared scrape owner) and sees the
container whether or not it exports anything; `up` is UNIVERSAL and carries a
`container` label for any target, so the rule needs no advance knowledge of what job
name a future per-app scrape picks. Nothing in it touches oauth2_proxy_*, so it
cannot be inert for the reason it is reporting. Run verbatim against live prod it
returns six namespaces and correctly excludes oauth2-proxy. It clears per namespace
as each gate gains a real scrape.

Also:
- promtool companion test pinning all four states: fires unscraped, quiet when
  scraped, honours for:1h, and STOPS once a scrape appears under a different job name.
- runbooks/oauth2-proxy-auth-failures.md — tell the gate's refusal from the group
  check from the app's own login from an XHR that silently got HTML, with the
  2026-09-30 jackett trace as the worked example and the Loki queries to use while
  there are no metrics. Registered in the configMapGenerator and README.
- runbooks/README.md gains an inert-alerts row for the unscraped sidecars.

Rejected: an alert on the shared proxy's oauth2_proxy_requests_total ratio. It needs
a conformance/ SCRAPE_OWNERS entry (human approval) and, decisively, would not have
fired on this incident — the shared instance is not in any sidecar-gated app's data
path and its current pods have proxied nothing. It would have looked like coverage
and been none.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-30 10:55:19 -05:00
84 changed files with 8342 additions and 226 deletions

No files matched your search

+168 -7
View File
@@ -4,6 +4,117 @@ Read this before doing anything durable. It is the delivery contract for AI codi
sessions and agents. The newest entry in `decisions/` is the current PM handoff and
takes precedence on *what* to work on; this file governs *how* it lands.
**Start with §0.** It is first because it is the rule you can break before you have read
anything else: the very first `git checkout -b` in the wrong directory has already done the
damage.
## 0. Work in your OWN workspace. Never in the shared checkout.
Before you create a branch, edit a file, or run `git` anything: **make yourself a git
worktree outside the repository root, and do all of your work there.** One workspace per
agent run, no exceptions.
# from the shared checkout, ONCE, at the start of your run
git fetch origin main
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
cd /path/outside/the/repo/wt-<slug> # everything after this happens HERE
`/path/outside/the/repo` means outside this repository's tree — your session scratchpad,
or a sibling like `/var/home/keith/source/wt-<slug>`. Branch from **`origin/main`**, never
from `main` and never from `HEAD`; see "stale base" below.
**The shared checkout is `/var/home/keith/source/home-network-k3s-impl`** — the operator's
working copy, the one you are probably started in. In it, do **not**:
- **`git checkout` / `git switch` / `git checkout -b`.** Its HEAD is shared state; moving
it moves the tree out from under whoever else is using it.
- **edit, create or delete a file.** Including "just one doc", including a file you intend
to move later.
- **`git add` / `git commit` / `git stash` / `git restore`.** `git add -A` there stages
whatever another agent left lying around; `git stash` *removes* their uncommitted work
into a place they will not look.
- **`git worktree remove` anything you did not create.**
Read-only commands there are fine, and the one write you do need is creating your own
worktree.
### Why — measured in the shared checkout, 2026-10-05
| What was measured | Value |
|---|---|
| `checkout: moving` entries in its reflog | **107** — the tree has been yanked between branches 107 times |
| the branch it was sitting on | `feat/pm-agent-issue-to-pr` — **not `main`** |
| uncommitted trees left in it by other runs | **2** (`infra/agent/`, `platform/pm-agent/`), mtimes five minutes apart the same evening |
| its local `main` vs `origin/main` | **two commits behind** (`4386d14` vs `22c94ef`) |
| registered worktrees | **42**, of which **31** are on branches already merged to `main` |
| gate scripts carrying an explicit `.claude/` exclusion | **9** |
Each row is a distinct way the shared checkout damages work:
- **Dirty tree, foreign files.** Two other runs' uncommitted directories were sitting in
it. An agent that edits there and runs `git add -A` ships someone else's half-finished
work inside its own PR, and the PR's author has no idea what those files are.
- **Stale base.** Its `main` was two commits behind `origin/main`, so
`git checkout -b x main` there branches off a tree that is already not what CI will merge
into — the gate you run locally proves a base nobody else has. Worse, it was not even *on*
`main`: a bare `git checkout -b x` would have branched off `feat/pm-agent-issue-to-pr` and
silently inherited that work into your PR. `git fetch` first, and name `origin/main`
explicitly as the start point.
- **Shared HEAD.** 107 branch switches in one tree is 107 chances to switch it while
another agent is mid-edit. The loser's changes are either carried onto the wrong branch
or lost to a checkout conflict — and the symptom surfaces in *their* session, not yours.
### Put the workspace OUTSIDE the repo root
A worktree *inside* the tree (`.claude/worktrees/agent-*/` — which is where Claude Code's
own default puts it) is a full second copy of the repo that every tree-walking check then
finds. Measured 2026-09-12: `find`-based discovery reported **the same manifest under test
once per worktree, up to 18 times**, and the L0 check comparing that listing against the
tree failed on paths that are not authored surface at all
(`platform/monitoring/tests/run.sh:115-123`). Nine gate scripts now carry an explicit
`.claude/` prune for this reason — which means the hazard is *worked around*, not fixed,
and the next check that walks the tree will hit it again. Keep the copy out of the tree and
none of that applies.
### Clean up — and expect not to be able to
31 of the 42 registered worktrees are on branches already merged to `main`. A registered
worktree **pins its branch**: while it exists, that branch cannot be deleted or checked out
anywhere else. So a left-behind workspace is a lock on a branch name, and the cost lands on
whoever next needs it.
Remove yours when you are done:
git worktree remove /path/outside/the/repo/wt-<slug>
Measured 2026-10-05: that command **succeeds** under the agent sandbox (rc 0, on a throwaway
worktree in the session scratchpad) — earlier sessions recorded it as blocked, and that
record was wrong, or at least no longer true. Try it; it probably works. If it is refused,
say so in your handoff and leave the worktree registered — do **not** `rm -rf` the directory,
which leaves a registered worktree pointing at nothing and still holding the branch.
If someone *else's* stale worktree is holding the branch you need, do not try to reclaim the
branch name. Push a `rework/<pr-number>` branch to the PR's refspec instead.
### If you are already standing in the shared checkout with edits
Stop. Do not `git stash` (that moves other agents' work too). Create your worktree as
above, redo the edits there, and leave the shared checkout's HEAD and working tree exactly
as you found them.
### Gating from a worktree
Three known breakages, all with workarounds, all required every run: see §3, "Running the
gate from a worktree".
### If you are writing a brief for another agent
The brief must name **its own explicit branch** and tell it to create **its own worktree**
from `origin/main`. "Work on the current branch" and "continue in the checkout" are how
branch sprawl and cross-contamination got here: an agent given no workspace uses the one it
is started in, which is the shared one. Also carry §3's three gate workarounds into the
brief — an agent that cannot gate will report rc 3 as green.
## 1. Never push to `main`. Ever.
Every durable change reaches `main` exactly one way:
@@ -36,6 +147,36 @@ sensitive path, so the policy cannot be widened or narrowed without a human.
That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a
missing credential is a lock-out, here a skip would be a bypass.
**The administrator override (2026-10-01).** The rule above was unsatisfiable for the one human
who runs this repo: Gitea refuses to let an author approve their own PR, and `jakman85` is the
only collaborator — so a sensitive-path PR the operator opened could never be merged, *including
the PR that would fix that.* An administrator may therefore waive the review by posting this on
the PR:
/approve-override <reason>
**Post it as a PR *review* of type "Comment", not in the plain comment box.** CI's
`SETTINGS_API_TOKEN` is minted `[read:repository, read:admin]`, and `/issues/{n}/comments` sits
behind Gitea's *issue* scope — so an override typed into the comment box is **invisible to CI**,
and the stage reports it as a missing approval rather than as a permission problem (measured on
PR #38, 2026-10-02). `/pulls/{n}/reviews` is readable, so a review body works today. Both sources
are accepted and the gate names which it used; the comment box starts working once
`infra/gitea/set-ci-token.sh` is re-run to pick up `read:issue`.
The directive must **start a line**, the **reason is mandatory**, the poster's `admin`/`owner`
permission is re-checked **live** when the gate runs, and it must be **newer than the
head commit** — push again and you must override again. The overrider, the timestamp and the
reason are printed in the gate log and the job summary. For an administrator, "a change to the
gate cannot be gated by the gate" is therefore **advisory, not structural**; what is kept is an
attributed record of each waiver. Note that the agent account `gitea-admin` also holds `owner`
here, so an agent *can* clear this on its own PR — **do not**: posting that directive to unblock
your own agent work is exactly the self-approval the stage exists to prevent. Get the operator
to post it, or move the change out of the sensitive paths.
**`required_approvals` on `main` is `0`**, not 1 — the gate is the approver. That drops the
approval *count* only: `block_admin_merge_override` and `enable_status_check` both remain `true`,
so **a red gate is unmergeable by everyone, administrators included.**
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
@@ -53,12 +194,19 @@ Verified against the live Gitea on 2026-09-29:
| `main` protected, direct push rejected | ✅ `protected: true`, `user_can_push: false` — **verified by a real rejected push**, not by the API's word |
| PR blocked until the gate passes | ✅ `enable_status_check: true`, context `gate / gate (pull_request)` |
| Gate runs on every PR | ✅ runner `incluster-dind` online; ~1m35s |
| Approval required before merge | ✅ `required_approvals: 1` |
| Gate cannot be overridden, by anyone | ✅ `block_admin_merge_override: true` |
| Human approval required before merge | ⬜ **No — `required_approvals: 0` since 2026-10-01.** The gate is the approver; sensitive paths are gated by `ci/approval-policy.sh` instead. See §1 |
All four were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
The first three were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
true. They are true now because each was measured, and the push rejection was provoked rather
than assumed. **Re-measure before relying on any of them** — see §2b.
The fourth row deliberately changed on 2026-10-01. `required_approvals: 1` was a **lock-out**, not
a protection: with a single collaborator and Gitea refusing author self-approval, nothing could
satisfy it (measured on PR #35 — green gate, zero reviews, unmergeable). It also contradicted the
model already written into `ci/approval-policy.sh` and `ci/gate.sh`. Rationale and the live-change
script: `infra/gitea/allow-admin-self-merge.sh`.
Still not covered: `vars.CLUSTER_AVAILABLE` is unset, so **L2/L3 conformance does not run in
CI**. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour.
@@ -110,15 +258,21 @@ Exit codes are a contract — do not flatten them:
| 3 | UNDETERMINED — a stage did not run. **Never report this as green.** |
`spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out
beside this one; that single skip is expected and tolerated. Any *other* skip is a
real gap in your run.
beside this one; that single skip is expected and tolerated — and from a worktree it is
avoidable outright (§3's worktree list, item 3). `gitea-settings` is the other expected
skip: it needs `SETTINGS_API_TOKEN`, which exists only in CI, so **a local run cannot
return 0** — the honest best local result is rc 3 with `gitea-settings` as the only stage
that did not run. Any *further* skip is a real gap in your run. Report the rc and name the
skipped stages; never round rc 3 up.
**Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`,
so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts.
### Running the gate from a git worktree
### Running the gate from a worktree
Two unrelated failures, both with known workarounds — put both in any agent brief:
This is the **normal** path, not an edge case — §0 requires you to be in a worktree, so
every gate run you do hits all of these. Three known breakages, all with
workarounds, all of them yours to apply every run — put them in any agent brief:
1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked
and exists only in the main checkout. Do **not** copy it (it is a credential).
@@ -129,10 +283,17 @@ Two unrelated failures, both with known workarounds — put both in any agent br
the `ENGINE` hook — a shim that intercepts `run` and re-execs
`podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in
`tracked_files()` is this bug, not a broken tree.
3. **`spec-immutability` skips** — the skip called tolerated above is also *avoidable*. `dev.sh`
derives the sibling spec path from its own `REPO_ROOT`, which in a worktree is your
workspace, so `../home-network-k3s` does not resolve and it adds no mount. Mount the spec
repo read-only in the same `ENGINE` shim and pass `-e SPEC_REPO=/home-network-k3s`: with
both mounts the stage reports `pass`, not SKIPPED, and R-TEST-06 is actually covered
(measured — `decisions/2026-10-01-render-selftest-runs-in-gate.md`).
## 4. Branch naming
Use `feat/`, `fix/`, `docs/`, `chore/`. Note that `.gitea/workflows/gate.yml`'s **push**
Use `feat/`, `fix/`, `docs/`, `chore/`. **One branch, one workspace, one PR** — if you
need a second branch, make a second worktree (§0); do not re-point an existing one. Note that `.gitea/workflows/gate.yml`'s **push**
trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run.
The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected.
+17
View File
@@ -11,6 +11,9 @@ specification** at [`../home-network-k3s`](../home-network-k3s).
- **Done = a green, reproducible, requirement-tagged conformance test** — never "merged," never a
manual demo (spec `docs/14`, `agents/definition-of-done.md`).
- **No work on `main`.** Every change is a feature branch merged into `main` (spec `CONTRIBUTING.md`).
- **No work in a shared checkout.** Every agent run gets its own git worktree, outside the repo
root, branched from `origin/main`. This checkout is shared mutable state — see
[`CLAUDE.md` §0](CLAUDE.md).
- **No plaintext secrets** — SOPS-encrypted only (spec `docs/07`).
- **Phase discipline** — only build what the active roadmap phase authorizes (spec `roadmap/phases.md`).
@@ -94,6 +97,20 @@ The running narrative — decisions, root causes, incidents, follow-ups — is
## Working on this repo
**First, get out of this checkout.** It is the operator's working copy and several agents
share it; concurrent runs here have overwritten each other's branches and left each other's
uncommitted files behind. One workspace per change:
```bash
git fetch origin main
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
cd /path/outside/the/repo/wt-<slug>
```
Outside the repo root specifically — a worktree under `.claude/worktrees/` is a second full
copy that tree-walking gate checks then count twice. The rule, the measurements behind it, and
the three gate workarounds a worktree needs are in [`CLAUDE.md` §0](CLAUDE.md) and §3.
Everything the gate needs is baked into the pinned dev container — **do not install the
toolchain on the host**:
+22
View File
@@ -100,6 +100,7 @@ spec:
- --allowed-group=platform-admins
- --skip-provider-button=true
- --redirect-url=https://jackett.${BASE_DOMAIN}/oauth2/callback
- --api-route=^/api/
- --cookie-name=_oauth2_proxy_jackett
- --cookie-domain=jackett.${BASE_DOMAIN}
- --whitelist-domain=.${BASE_DOMAIN}
@@ -243,6 +244,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): jackett-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: jackett-allow-monitoring-scrape
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: jackett
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): jackett-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+2
View File
@@ -14,10 +14,12 @@ resources:
- nextcloud-db-backup-credentials-prod.sops.yaml
- nextcloud-db-backup-credentials.sops.yaml
- nextcloud-db.yaml
- pihole-bootstrap-secret.sops.yaml
- pihole-dns.yaml
- pihole-oauth2-proxy-credentials.sops.yaml
- pihole.yaml
- plex-claim-secret.sops.yaml
- plex-remote.yaml
- plex.yaml
- qbittorrent-bootstrap-secret.sops.yaml
- qbittorrent-oauth2-proxy-credentials.sops.yaml
@@ -0,0 +1,72 @@
# =================================================================================================
# PiHole WEB/API ADMIN PASSWORD — the one piece of PiHole state that was NOT declared anywhere.
# =================================================================================================
# WHY THIS FILE EXISTS. Measured on live prod 2026-10-04, `pihole.toml` marks every non-default
# setting with `### CHANGED`, and names the ones that came from the environment `### CHANGED (env)`.
# The complete list was:
#
# dns.upstreams ### CHANGED (env) <- declared, catalog/pihole.yaml
# dns.listeningMode ### CHANGED (env) <- declared, catalog/pihole.yaml
# dns.rateLimit.count ### CHANGED (env) <- declared, catalog/pihole.yaml
# misc.dnsmasq_lines ### CHANGED (env) <- declared, catalog/pihole.yaml
# files.macvendor ### CHANGED <- set by the image itself, container path
# webserver.api.pwhash ### CHANGED <- SET OUT OF BAND. Declared NOWHERE. This file.
#
# So exactly ONE operator-set value lived only on the Longhorn PVC: the admin password, as a
# one-way BALLOON-SHA256 hash. Nobody could recover it (a hash does not reverse), nothing in git
# produced it, and a rebuild from git would have come up with a DIFFERENT password — or none.
#
# THE REASON THIS BLOCKS HA, which is why it is fixed first. The next step is running more than one
# PiHole replica, and a replica with a fresh PVC would come up with its own unrelated password. The
# admin UI would then accept or reject the same credential depending on which pod answered. Declaring
# it here makes every replica, and every rebuild, identical by construction.
#
# MECHANISM: `webserver.api.password` is a real, settable FTL config key — verified in the running
# image, where `pihole setpassword` is itself just `setFTLConfigValue "webserver.api.password"` with
# the comment "pihole-FTL will automatically hash the password". So FTL takes CLEARTEXT on this key
# and writes the hash into `webserver.api.pwhash` itself. catalog/pihole.yaml therefore passes this
# value as `FTLCONF_webserver_api_password`, and the hash on the PVC becomes a derived artefact
# rather than the source of truth.
#
# DEFENCE IN DEPTH, NOT THE ONLY GATE. The admin UI is already behind the oauth2-proxy sidecar
# (`--allowed-group=platform-admins`), so this password is a SECOND gate. It is kept rather than set
# empty because the `pihole` Service still publishes port 80 (PiHole direct, bypassing the proxy on
# :4180) and only the CiliumNetworkPolicy closes it — and the default-deny policy in this repo has a
# documented drift history. An unauthenticated admin UI that can rewrite LAN DNS is not a good thing
# to leave resting on one policy staying correct. Narrowing that Service port is tracked follow-up.
#
# ROTATION: `sops` this file in your own terminal, change the value, commit. Flux re-applies the
# Secret, the pod restarts, FTL re-hashes. Do NOT `sops -d` it into a shared terminal or a log
# (CLAUDE.md section 6); read it in an editor. The value below was generated randomly at authoring
# time and has never been printed to a transcript.
apiVersion: v1
kind: Secret
metadata:
name: pihole-bootstrap
namespace: pihole
labels:
app.kubernetes.io/name: pihole
type: Opaque
stringData:
#ENC[AES256_GCM,data:TMOuRyJeb5IVn3lZPXY7lgh1KGxu7GY9+WIOLHpMB50JqzmM2tHIaWUWKTt1JtZoX/E5/Y7WCUTnfkJLhRBN2zV2MwWRBSLYVy5CMg7xV8PTLRZJYAVUYS5Trg==,iv:q+DpfT6PQfY5ZuNsmP7Ddl+PDLjSm89fJICAhYoW64k=,tag:axijaO3DaxIZgZ3LeK2hXA==,type:comment]
webui-password: ENC[AES256_GCM,data:7FcK/bUW79M46f/MlouAmATKbMnoUTNuMUjtqJ5vpU4=,iv:I8DBFB39uXZV42Ie2IPwSefmMgdKEtiDknfc9Tu7h5E=,tag:L423784UrDU3TvylpHc6KQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0MkRSNjZLZlpXYkcvaVJL
cjlqb2wrcWREZnZLdGpsTHNPWEpseHlRZnpjCnJVM1FDQ2M2dXlaS1JBZ2k2V1BP
Yi9QUDZSMzZsL09pTi93RTdjdGxtY2MKLS0tIDIrQTZIVWdhMGIxSCtlUFN2Mlho
SWVybld4VjJyaHRSKzZZU0xsUUV2SEEKbDYHV/qBZVspK7DpBW3vYjRMJ/HmAN+9
G9bOlNEp0jAtcNZEn6lpQ9ORgvmIrhJHoBQRLabdMWzNWSZsNn56tA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-04T06:01:27Z"
mac: ENC[AES256_GCM,data:rhTMTOwLOtHXHgqI5M2ZW/6w+UpLpxBlTk7NoN0wmGSqOcE+OGrXA1DhkZrJweyQT0RSNSSBm/6QMztLP81eepOzdp2rEkwvbQPK/l6K9g16Ww9c1IatS3NQsKEB8AoMDx7jBQ6exXYgggPx3MJsNYbE+WyloQ96U9KPdrecAM8=,iv:n34v2zX9D5xsaR1PFCH94mNMiuRfhUyI2lseUCoqiTA=,tag:9YmN9nIVLXfsGYIb0bUl2w==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
+75 -29
View File
@@ -35,34 +35,18 @@ spec:
- protocol: TCP
port: 53
---
# PVC pihole-config (R-STATE)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pihole-config
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi
---
# Deployment (R-PLAT-02)
# StatefulSet (R-PLAT-02)
apiVersion: apps/v1
kind: Deployment
kind: StatefulSet
metadata:
name: pihole
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
replicas: 1
strategy:
type: Recreate
replicas: 2
serviceName: pihole
podManagementPolicy: Parallel
selector:
matchLabels:
app.kubernetes.io/name: pihole
@@ -89,10 +73,22 @@ spec:
- name: FTLCONF_dns_rateLimit_count
value: '0'
- name: FTLCONF_misc_dnsmasq_lines
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP}
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
- name: FTLCONF_webserver_api_password
valueFrom:
secretKeyRef:
name: pihole-bootstrap
key: webui-password
volumeMounts:
- name: config
mountPath: /data/config
mountPath: /etc/pihole
lifecycle:
postStart:
exec:
command:
- /bin/bash
- -c
- "# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and\n# does NOT run a gravity rebuild.\nset -u\nGRAVITY=/etc/pihole/gravity.db\n\n# THE DECLARED SET \u2014 one URL per line, the source of truth for what PiHole blocks.\n# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.\nDECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'\n\n# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,\n# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.\ni=0\nwhile [ ! -s \"$GRAVITY\" ] && [ \"$i\" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done\nif [ ! -s \"$GRAVITY\" ]; then\n echo \"pihole-adlists: gravity.db absent after 180s - leaving adlists untouched\" >&2\n exit 0\nfi\n\nsql() { pihole-FTL sqlite3 \"$GRAVITY\" \"$1\"; }\nCHANGED=0\n\n# 1) Every declared URL must be present AND enabled.\necho \"$DECLARED\" | while IFS= read -r url; do\n [ -n \"$url\" ] || continue\n printf '%s\\n' \"$url\"\ndone > /tmp/declared.txt\nwhile IFS= read -r url; do\n [ -n \"$url\" ] || continue\n # Single quotes inside a URL would break this SQL; adlist URLs do not contain them.\n have=$(sql \"select count(*) from adlist where address='$url';\")\n if [ \"$have\" = \"0\" ]; then\n echo \"pihole-adlists: adding $url\"\n sql \"insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');\"\n CHANGED=1\n else\n en=$(sql \"select enabled from adlist where address='$url';\")\n if [ \"$en\" != \"1\" ]; then\n echo \"pihole-adlists: re-enabling $url\"\n sql \"update adlist set enabled=1 where address='$url';\"\n CHANGED=1\n fi\n fi\ndone < /tmp/declared.txt\n\n# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).\nsql \"select address from adlist where enabled=1;\" | while IFS= read -r addr; do\n [ -n \"$addr\" ] || continue\n if ! grep -qxF \"$addr\" /tmp/declared.txt; then\n echo \"pihole-adlists: disabling undeclared $addr\"\n sql \"update adlist set enabled=0 where address='$addr';\"\n echo changed >> /tmp/adlist-changed\n fi\ndone\n[ -s /tmp/adlist-changed ] && CHANGED=1\nrm -f /tmp/adlist-changed /tmp/declared.txt\n\n# 3) Rebuild gravity ONLY if the declared set actually moved.\nif [ \"$CHANGED\" = \"1\" ]; then\n echo \"pihole-adlists: declared set changed - running gravity update\"\n pihole -g || echo \"pihole-adlists: gravity update failed; existing gravity.db retained\" >&2\nelse\n echo \"pihole-adlists: already matches the declared set - nothing to do\"\nfi\nexit 0\n"
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
@@ -163,10 +159,39 @@ spec:
port: http
initialDelaySeconds: 5
periodSeconds: 15
volumes:
- name: config
persistentVolumeClaim:
claimName: pihole-config
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: pihole
topologyKey: kubernetes.io/hostname
volumeClaimTemplates:
- metadata:
name: config
labels:
app.kubernetes.io/name: pihole
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi
---
# PodDisruptionBudget — keep one replica through a drain (availability.replicas)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: pihole
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: pihole
---
# Service
apiVersion: v1
@@ -207,6 +232,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): pihole-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: pihole-allow-monitoring-scrape
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: pihole
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): pihole-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -269,11 +315,11 @@ spec:
- name: pihole.slis
rules:
- alert: pihole-down
expr: kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"} == 0 or absent(kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"})
expr: kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"} == 0 or absent(kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"})
for: 5m
labels:
severity: critical
service: pihole
annotations:
summary: pihole is down
description: 'pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available).'
description: 'pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready).'
+126
View File
@@ -0,0 +1,126 @@
---
# =================================================================================================
# PLEX REMOTE ACCESS — raw tcp/32400 north-south exposure, and the policy that admits it.
# =================================================================================================
# HAND-WRITTEN, NOT RENDERED. A non-catalog app-tier unit anchored to the `plex` ServiceDefinition
# and placed into wave 0 by `catalog/render.py --emit-waves` via its EXTRA_UNITS table — the same
# mechanism gitea-actions and pihole-dns use. It is hand-written for the SAME reason those are:
# schemas/servicedefinition.schema.json is `additionalProperties: false` and has NO field for a
# type=LoadBalancer Service, for a pinned lbipam VIP, or for raw-TCP north-south exposure at all.
# `ingressRoutes` renders HTTPRoutes on the shared Gateway, which is an L7 HTTPS terminator.
#
# WHY THE L7 GATEWAY IS NOT ENOUGH HERE — and it is NOT because the Gateway fails to serve Plex.
# Measured 2026-10-03 through the Gateway VIP: `GET https://plex.home.keithjackson.dev/identity`
# returned HTTP 200 with `claimed="1"` and the real machineIdentifier, and the protected endpoints
# (/library/sections, /status/sessions, /:/prefs) all returned 401. The L7 path WORKS.
#
# The problem is DISCOVERY, not proxying. Plex apps (phone, TV, Roku) do not resolve
# `plex.${BASE_DOMAIN}`; they ask plex.tv for the server's connection list. A server gets onto that
# list one of two ways: it advertises a custom URL (`customConnections`, the ADVERTISE_IP env), or it
# publishes a reachable public IP:port for its own tcp/32400. Measured in the live Preferences.xml on
# 2026-10-03: `customConnections` ABSENT, `ManualPortMappingMode` ABSENT, while
# `PublishServerOnPlexOnlineKey="1"` — the server was trying to publish and had nothing reachable to
# publish, so remote clients saw NO connection at all. The operator chose the NATIVE path (this
# file) over advertising the Gateway URL: Plex then terminates its own TLS with its
# `*.plex.direct` certificate and video streams bypass the Envoy L7 hop entirely.
#
# VIP: ${PLEX_L4_VIP} (cluster-vars, R-ENV-02 — dev 192.168.1.57, prod 192.168.2.24), PINNED via
# lbipam.cilium.io/ips out of the CiliumLoadBalancerIPPool `default-pool`
# (${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP}) and ARP-announced on the flat LAN by the existing
# CiliumL2AnnouncementPolicy `default-l2` (platform-config/cilium/*). No new Cilium config is needed;
# this Service just consumes the pool. PINNING IS NOT OPTIONAL: the address is typed into a WAN
# port-forward on the SOHO router, which is hand-maintained and off-repo, so it can never move.
# 192.168.2.24 verified FREE live 2026-10-03 — `default-pool` reported IPsTotal 11 / IPsUsed 4 /
# IPsAvailable 7, and the only four allocations were .20 (cilium-gateway-shared), .21 (unifi-l4-*),
# .22 (crafty-l4-*) and .23 (pihole-dns-*).
#
# ONE SERVICE, TCP ONLY. Unlike unifi/crafty/pihole-dns this needs no second Service and no
# lbipam.cilium.io/sharing-key: Plex's remote access is tcp/32400 and nothing else. (Plex's
# DLNA/GDM discovery uses UDP, but that is LAN-only multicast and is not part of remote access.)
#
# NO `loadBalancerSourceRanges`, DELIBERATELY — and this is the one place this file diverges from
# its closest sibling. apps/wave-0/pihole-dns.yaml pins its source range to ${LAN_CIDR} because a
# resolver must never answer the internet. This Service is the OPPOSITE case: admitting remote
# clients from arbitrary internet addresses is its entire purpose, so there is no range to narrow
# to. Plex authenticates every caller itself (measured above: 401 on every protected endpoint
# without a token), which is what makes an unfiltered port defensible here.
#
# NO external-dns ANNOTATION, DELIBERATELY. unifi and crafty both carry
# `external-dns.alpha.kubernetes.io/hostname` so external-dns publishes their VIP as a public
# Cloudflare record. This Service must not, for pihole-dns's reason: ${PLEX_L4_VIP} is an RFC1918
# address that is useless in public DNS. Remote clients get this server's address from plex.tv, not
# from the zone. The existing public `plex.${BASE_DOMAIN}` record is published from the rendered
# HTTPRoute via the gateway source and is NOT affected by this file — the web UI keeps working
# through the Gateway exactly as before.
#
# -------------------------------------------------------------------------------------------------
# externalTrafficPolicy: Cluster — inherited from crafty's 8-DAY LAN OUTAGE, not re-derived.
# -------------------------------------------------------------------------------------------------
# `Local` is the tempting choice because it preserves the client source IP, which this service
# actually has a use for (see the allowedNetworks note below). It is still WRONG here, for the
# reason platform/crafty/service-l4.yaml documents at length: Cilium's L2 announcer elects ONE lease
# holder per Service and does NOT follow the backend pod, so with `Local` the VIP can be announced
# by a node that has no Plex pod, which black-holes the port while every object still reports
# healthy. Plex is a single-replica Deployment with `strategy: Recreate` pinned to wherever the
# scheduler put it, so the mismatch is the normal case, not a rare one. Take the SNAT.
#
# KNOWN CONSEQUENCE OF THAT CHOICE, stated rather than discovered later: `Cluster` SNATs the client
# to the ingress NODE's address (192.168.2.1x), which falls inside the `192.168.0.0/16` entry of
# catalog/plex.yaml's ALLOWED_NETWORKS. Plex will therefore classify every REMOTE client as LAN.
# Measured 2026-10-03 on the Gateway path, which SNATs the same way: Plex logged the request as
# `[10.0.2.214:36040 (Allowed Network (WAN))]`. This is a METERING effect, not a security one —
# authorization is unchanged (401s above), but Plex's remote-quality/bandwidth limits will not bind
# on these streams because it believes they are local. Narrowing ALLOWED_NETWORKS cannot fix it
# without also de-classifying genuine LAN clients, so it is accepted.
apiVersion: v1
kind: Service
metadata:
name: plex-l4-tcp
namespace: plex
labels:
app.kubernetes.io/name: plex
annotations:
lbipam.cilium.io/ips: "${PLEX_L4_VIP}"
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
selector:
app.kubernetes.io/name: plex
ports:
- name: plex-remote
port: 32400
targetPort: 32400
protocol: TCP
---
# INGRESS allow for the raw port (R-NET-01/02).
#
# The rendered plex.yaml already carries `plex-allow-gateway-ingress` (fromEntities: ingress ->
# 32400) and three reciprocal allows for sonarr/radarr/seerr. CiliumNetworkPolicies are ADDITIVE, so
# this file adds the off-cluster case without touching those.
#
# `world` is Cilium's entity for anything outside the cluster, which is what a remote Plex client is.
# `host` and `cluster` mirror platform/crafty/networkpolicy.yaml's game-port rule and are what make
# this work under `externalTrafficPolicy: Cluster`: after SNAT the packet's source is the ingress
# NODE, which Cilium classifies as `host` (same node) or `remote-node` (a different one) — NOT
# `world`. Listing `world` alone would compile cleanly and then drop every remote stream, so all
# four entities are named.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plex-allow-remote-32400
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: plex
ingress:
- fromEntities:
- world
- cluster
- host
- remote-node
toPorts:
- ports:
- { port: "32400", protocol: TCP }
+66
View File
@@ -137,6 +137,20 @@ spec:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
- name: ADVERTISE_IP
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
- name: PLEX_TLS_CRT
valueFrom:
secretKeyRef:
name: plex-vip-tls
key: tls.crt
optional: true
- name: PLEX_TLS_KEY
valueFrom:
secretKeyRef:
name: plex-vip-tls
key: tls.key
optional: true
resources:
limits:
gpu.intel.com/i915: 1
@@ -146,6 +160,14 @@ spec:
- name: media
mountPath: /media
readOnly: true
lifecycle:
postStart:
exec:
command:
- /bin/bash
- -c
- "# NOTE \u2014 LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.\n# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a\n# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD\n# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be\n# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` \u2014 bare. Only\n# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be\n# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell\n# variable written with braces would be blanked or would fail the whole Kustomization \u2014\n# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and\n# catalog/render.py now refuses to emit it (which is how this very comment got caught).\n# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the\n# certificate has not issued yet they are genuinely UNSET, not empty \u2014 and under `set -u`\n# the first `[ -n \"$PLEX_TLS_CRT\" ]` would abort the script, which for a postStart hook\n# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists\n# to prevent, so turning it back on here would defeat the whole design. (The alternative,\n# a brace-wrapped `:-` default, would need envsubst escaping \u2014 the trap the note above\n# describes.) Every value below is explicitly guarded instead.\nPX=\"/config/Library/Application Support/Plex Media Server/Preferences.xml\"\nCERTDIR=/config/certs\nP12=$CERTDIR/plex-vip.p12\n# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it\n# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a\n# container-format requirement, not an access control.\nP12PASS=plexvip\nLANNETS='10.0.0.0/8,192.168.0.0/16'\nCERTDOMAIN=plex-vip.${BASE_DOMAIN}\nAPI=http://127.0.0.1:32400\n\n# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so\n# a cert-manager RENEWAL is picked up with no extra moving part.\nif [ -n \"$PLEX_TLS_CRT\" ] && [ -n \"$PLEX_TLS_KEY\" ]; then\n mkdir -p $CERTDIR && chmod 700 $CERTDIR\n printf '%s' \"$PLEX_TLS_CRT\" > $CERTDIR/tls.crt\n printf '%s' \"$PLEX_TLS_KEY\" > $CERTDIR/tls.key\n chmod 600 $CERTDIR/tls.key\n if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \\\n -in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then\n mv -f $P12.tmp $P12\n chown 1000:1000 $P12 2>/dev/null || true\n chmod 600 $P12\n echo \"[plex-settings] PKCS#12 bundle written\"\n else\n rm -f $P12.tmp\n echo \"[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle\" >&2\n fi\n rm -f $CERTDIR/tls.key\nelse\n echo \"[plex-settings] WARN: TLS env empty; certificate not installed this start\" >&2\nfi\n\n# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.\ni=0\nwhile [ \"$i\" -lt 45 ]; do\n if [ \"$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)\" = \"200\" ]; then\n break\n fi\n i=$(expr $i + 1); sleep 2\ndone\n\n# (3) The prefs API needs the server token. It already lives in Preferences.xml on this\n# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An\n# UNCLAIMED server has none \u2014 a normal state on a fresh /config \u2014 so skip, do not fail.\nTOKEN=$(grep -o 'PlexOnlineToken=\"[^\"]*\"' \"$PX\" 2>/dev/null | head -1 | cut -d'\"' -f2)\nif [ -z \"$TOKEN\" ]; then\n echo \"[plex-settings] not claimed yet \u2014 skipping prefs, will retry next start\" >&2\n exit 0\nfi\n\n# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on\n# a PUT, so a raw '/' or ',' would be read as structure rather than data.\nenc() {\n printf '%s' \"$1\" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \\\n -e 's|:|%3A|g' -e 's| |%20|g'\n}\nsetpref() {\n code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT\
\ \\\n -H \"X-Plex-Token: $TOKEN\" \"$API/:/prefs?$1=$(enc \"$2\")\" 2>/dev/null)\n if [ \"$code\" = \"200\" ]; then\n echo \"[plex-settings] set $1\"\n else\n echo \"[plex-settings] WARN: $1 -> HTTP $code\" >&2\n fi\n}\n\n# (4) THE LAN DEFINITION \u2014 the reason this hook was written. Without it Plex has NO notion\n# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests\n# logged \"Allowed Network (WAN)\" and ZERO \"(LAN)\", including Seerr from inside the\n# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks\n# (the ALLOWED_NETWORKS env) only waives AUTH \u2014 it does NOT make a client local. Two\n# different settings; only one of them has an env hook.\nsetpref LanNetworksBandwidth \"$LANNETS\"\n\n# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only\n# if the bundle exists \u2014 pointing Plex at a missing file would break its TLS listener.\nif [ -s \"$P12\" ]; then\n setpref customCertificatePath \"$P12\"\n setpref customCertificateKey \"$P12PASS\"\n setpref customCertificateDomain \"$CERTDOMAIN\"\nelse\n echo \"[plex-settings] WARN: no bundle; leaving certificate prefs untouched\" >&2\nfi\nexit 0\n"
volumes:
- name: config
persistentVolumeClaim:
@@ -170,6 +192,28 @@ spec:
port: 32400
targetPort: 32400
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-radarr-32400
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plex-allow-radarr-32400
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: plex
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: radarr
k8s:io.kubernetes.pod.namespace: radarr
toPorts:
- ports:
- port: '32400'
protocol: TCP
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-seerr-32400
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -192,6 +236,28 @@ spec:
- port: '32400'
protocol: TCP
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-sonarr-32400
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plex-allow-sonarr-32400
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: plex
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: sonarr
k8s:io.kubernetes.pod.namespace: sonarr
toPorts:
- ports:
- port: '32400'
protocol: TCP
---
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): plex-allow-gateway-ingress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+21
View File
@@ -319,6 +319,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): qbittorrent-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: qbittorrent-allow-monitoring-scrape
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: qbittorrent
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+8 -6
View File
@@ -1,6 +1,8 @@
# promtool rule-unit tests for the wave-0 per-app `<app>-down` SLI rules.
# Manifests under test: every PrometheusRule in apps/wave-0/ — gitea, jackett, pihole, plex,
# qbittorrent (container/Deployment) and vm-demo (KubeVirt VMI). The harness discovers this file by
# qbittorrent (container/Deployment), pihole (container/StatefulSet — `availability.replicas: 2`, so
# its SLI reads kube_statefulset_status_replicas_ready; see schemas/renderer-contract.md
# `### availability`) and vm-demo (KubeVirt VMI). The harness discovers this file by
# the SUITE convention (`<dir>/tests/*.test.yaml`) and flattens those RENDERED manifests, so these
# tests run against the exact expressions Flux applies, never a copy.
#
@@ -41,7 +43,7 @@ tests:
values: '1+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
values: '1+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
values: '1+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
values: '1+0x20'
@@ -81,7 +83,7 @@ tests:
values: '1+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
values: '1+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
values: '1+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
values: '0+0x20'
@@ -132,7 +134,7 @@ tests:
values: '0+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
values: '0+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
values: '0+0x20'
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
values: '1+0x20'
@@ -170,10 +172,10 @@ tests:
severity: critical
service: pihole
namespace: pihole
deployment: pihole
statefulset: pihole
exp_annotations:
summary: "pihole is down"
description: "pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
description: "pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready)."
- eval_time: 10m
alertname: qbittorrent-down
exp_alerts:
+45
View File
@@ -153,6 +153,8 @@ spec:
- --allowed-group=platform-admins
- --skip-provider-button=true
- --redirect-url=https://radarr.${BASE_DOMAIN}/oauth2/callback
- --api-route=^/api/
- --api-route=^/signalr/
- --cookie-name=_oauth2_proxy_radarr
- --cookie-domain=radarr.${BASE_DOMAIN}
- --whitelist-domain=.${BASE_DOMAIN}
@@ -281,6 +283,28 @@ spec:
- port: '9117'
protocol: TCP
---
# CiliumNetworkPolicy egress allow (R-NET-02): radarr-allow-plex-32400
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: radarr-allow-plex-32400
namespace: radarr
labels:
app.kubernetes.io/name: radarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: radarr
egress:
- toEndpoints:
- matchLabels:
app.kubernetes.io/name: plex
k8s:io.kubernetes.pod.namespace: plex
toPorts:
- ports:
- port: '32400'
protocol: TCP
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): radarr-allow-seerr-7878
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -323,6 +347,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): radarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: radarr-allow-monitoring-scrape
namespace: radarr
labels:
app.kubernetes.io/name: radarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: radarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): radarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+43
View File
@@ -281,6 +281,28 @@ spec:
- port: '9117'
protocol: TCP
---
# CiliumNetworkPolicy egress allow (R-NET-02): sonarr-allow-plex-32400
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sonarr-allow-plex-32400
namespace: sonarr
labels:
app.kubernetes.io/name: sonarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: sonarr
egress:
- toEndpoints:
- matchLabels:
app.kubernetes.io/name: plex
k8s:io.kubernetes.pod.namespace: plex
toPorts:
- ports:
- port: '32400'
protocol: TCP
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): sonarr-allow-seerr-8989
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -323,6 +345,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sonarr-allow-monitoring-scrape
namespace: sonarr
labels:
app.kubernetes.io/name: sonarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: sonarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+17
View File
@@ -51,6 +51,23 @@ auth:
# This grants no real privilege: platform-admins already maps to cluster-admin via
# k8s-oidc-rbac.yaml (oidc:platform-admins), so a member can already exec into these pods.
- platform-admins # operators of these services must not be locked out of them
# Answer Jackett's OWN API with 401 instead of a 302 to Keycloak (schema 1.9.0; renders
# --api-route, singular and repeatable, onto the oauth2-proxy sidecar).
#
# MEASURED on this pod's access log, 2026-09-30: on /api/ paths, 33 x 302 against 2 x 401 —
# and 4 of 6 POSTs were answered BY THE PROXY and never reached Jackett (access-log upstream
# field `-`, not `/`), including both `/api/v2.0/server/update` calls. Jackett's jQuery does
# not send the exact `Accept: application/json` oauth2-proxy's AJAX guard requires, so the
# 302 was followed invisibly, satisfied silently by the live Keycloak SSO session, and
# resolved 200 carrying the dashboard's HTML where the JavaScript expected JSON. A browser
# following a 302 also re-issues a POST as a GET and DROPS THE BODY, which is why "adding an
# indexer silently does nothing" was the user-visible symptom and nothing logged a failure.
#
# SCOPE: `/api/` only. Jackett serves its UI at /UI/ and its static assets elsewhere, and a
# browser never NAVIGATES to /api/ — so a plain page load is still 302'd into a silent SSO
# sign-in, which is the behaviour that must not change.
apiRoutes:
- ^/api/
update:
channel: stable # R-UPD-01
range: ">=0.22.0" # stay on the Jackett 0.22.x stable line
+204 -1
View File
@@ -12,6 +12,42 @@ storage:
- name: config
size: 2Gi
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
# THE PVC WAS MOUNTED SOMEWHERE PIHOLE NEVER WRITES, AND SO HELD NOTHING AT ALL. Without an
# explicit mountPath the renderer defaults to /data/<name>, i.e. /data/config — but PiHole keeps
# every piece of its state in /etc/pihole. MEASURED on live prod 2026-10-04: `/etc/pihole` (7.1 M
# — gravity.db, pihole.toml, the query DB) was on the **overlay** filesystem, i.e. the
# container's EPHEMERAL layer, while `/dev/longhorn/pvc-849edb86...` sat mounted at
# /data/config holding `lost+found` and 24 K of nothing. Three consequences, all live until now:
# * every restart silently discarded PiHole's whole state, re-downloading 72 k gravity domains
# and regenerating the admin password hash;
# * `backup.policy: daily-offcluster` below has been faithfully backing up an EMPTY volume,
# which is worse than no backup because it reports success;
# * it is also why phase 1's password/adlist declaration mattered more than it first appeared —
# that state was not merely undeclared, it was ephemeral.
# This is the same class of bug catalog/plex.yaml's `config` entry documents ("Without this the
# PVC sat unused and Plex wrote to the container's EPHEMERAL layer").
#
# /etc/pihole IS THE IMAGE'S INTENDED MOUNT POINT, and an EMPTY volume there self-initialises.
# Verified in the running image, not assumed:
# * `/usr/bin/start.sh` (PID 1) calls `migrate_gravity`, whose `migrate_to_database()` creates
# the gravity database when `[ ! -e "$gravityDBfile" ]` — the same branch the postStart hook
# below relies on, and the branch that produced the current ephemeral copy.
# * FTL writes `pihole.toml` itself from the `FTLCONF_*` env above (that is what the
# `### CHANGED (env)` markers in it are).
# * NO `podSecurityContext.fsGroup` is needed and adding one would be inert: `start.sh` line 5
# is `chown -R pihole:pihole /etc/pihole/ /var/log/pihole/` and PID 1 runs as **uid 0**, so
# the image fixes ownership of a fresh root-owned volume before anything else starts. Same
# arrangement as plex's image managing its own /config ownership.
# * the image's own default for `files.macvendor` was moved OUT of this directory (to
# `/macvendor.db`), which is what a maintainer does to a path they expect to be a volume.
#
# WHY IT IS FIXED IN THIS BRANCH rather than left for its own: `availability.replicas: 2` renders
# per-replica volumeClaimTemplates, and with the wrong mountPath those would be two 2 Gi Longhorn
# volumes (times three Longhorn replicas each) holding nothing but `lost+found` while all state
# stayed ephemeral — paying the cost of persistence and getting none of it. It also REMOVES this
# change's main operational risk: with gravity.db persisted, a pod restart is cheap, instead of
# every restart re-downloading 72 k domains inside a wave that has a 5 m timeout.
mountPath: /etc/pihole
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
# ------------------------------------------------------------------------------------------------
# LAN SPLIT-HORIZON DNS — the half of R-DNS-03 that platform/coredns/coredns-custom.yaml defers to.
@@ -98,8 +134,64 @@ env:
# then silently wrong: FTL stores `["a","b"]` as ONE array element containing those literal
# characters, writes that single bogus line into /etc/pihole/dnsmasq.conf and reports "0 invalid"
# while resolution for the zone does not work. Confirmed both ways against the live image.
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the LAN HALF of Plex's single advertised
# hostname. Plex publishes ONE URL to plex.tv for both local and remote clients
# (catalog/plex.yaml ADVERTISE_IP): on the LAN this answer points it at the tcp/32400
# LoadBalancer, while Cloudflare answers the same name publicly with the WAN address, NATed to
# the same VIP. WITHOUT THIS LINE the entry above answers it with ${GATEWAY_VIP} via the zone
# wildcard, where NOTHING listens on 32400 — measured 2026-10-04, plex-vip.${BASE_DOMAIN}
# resolved to the Gateway VIP and a connect to :32400 there returned http=000. That is a silent
# failure: the Plex web UI on plex.${BASE_DOMAIN} keeps working through the Gateway on 443 while
# every client that takes the ADVERTISED connection cannot reach the server at all.
- name: FTLCONF_misc_dnsmasq_lines
value: "address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP}"
value: "address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
# THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from
# apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which
# carries the full rationale and the measurement that found it.
#
# `webserver.api.password` takes CLEARTEXT and FTL hashes it itself into `webserver.api.pwhash`.
# Verified in the running image, not assumed: `pihole setpassword` is itself
# `setFTLConfigValue "webserver.api.password" "$PASSWORD"`, commented upstream as "pihole-FTL will
# automatically hash the password". So the BALLOON-SHA256 hash on the PVC is now a DERIVED artefact
# and this is its source of truth. Rotating the Secret rotates the password.
- name: FTLCONF_webserver_api_password
valueFrom:
secretKeyRef:
name: pihole-bootstrap
key: webui-password
availability:
# TWO REPLICAS, ON DIFFERENT NODES. LAN DNS is a critical network service and it was ONE pod on
# ONE node. Measured 2026-10-04, that cost more than "until it reschedules":
# * the node running the pod dies -> ~40s to mark it NotReady, then `tolerationSeconds: 300`
# before the pod is even evicted, then reschedule, then Longhorn must force-detach the RWO
# volume from a dead node and reattach it. Roughly 6-10 MINUTES with no LAN resolution.
# * the node holding the `pihole-dns-*` L2 lease dies -> Cilium re-elects an announcer, tens of
# seconds. (These were DIFFERENT nodes: lease on k3s-prod-2, pod on k3s-prod-1. Two of three
# nodes were single points of failure for LAN DNS, with wildly different blast radii.)
# The Services already tolerate this: `externalTrafficPolicy: Cluster` on the DNS LoadBalancers
# (apps/wave-0/pihole-dns.yaml) means the L2 lease holder forwards to ANY ready backend, so a
# second replica is picked up with no change there.
#
# WHAT THIS DOES NOT FIX, and it is the bigger risk: LAN DNS still depends on the CLUSTER. etcd
# quorum loss, a Cilium regression, a Longhorn problem or a bad reconcile takes DNS down
# everywhere regardless of replica count. The only thing that removes that dependency is a
# resolver outside the cluster carrying the same `address=` lines. Considered and not chosen
# (operator decision, 2026-10-04); see decisions/2026-10-04-pihole-multi-replica.md.
#
# THE PREREQUISITE WAS DONE FIRST, and this field would have been actively harmful without it. A
# replica gets a FRESH volume, and until 2026-10-04 PiHole's admin password and adlists lived only
# on the PVC — so replica 1 would have come up SERVING DNS AND BLOCKING NOTHING, with a different
# password, and answers would have varied by which pod replied. Both are now declared (the
# password as a SOPS Secret -> FTLCONF_webserver_api_password; the adlists reconciled into
# gravity.db by the postStart hook below), which is what makes a fresh replica identical to its
# sibling. See decisions/2026-10-04-pihole-declare-state.md.
#
# WHY 2 AND NOT 3. Two tolerates the loss of any single node, which is the stated goal, and leaves
# a spare node for the evicted replica to land on under the HARD anti-affinity the renderer emits.
# Three would pin one replica per node with no spare, so a single node failure would leave a
# replica permanently Pending and — `apps-wave-0` being `wait: true` — stall the app tier for as
# long as the node was down. Two is the count that degrades gracefully here.
replicas: 2
ingressRoutes:
- host: pihole.${BASE_DOMAIN}
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
@@ -107,6 +199,117 @@ auth:
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no native OIDC (docs/15; R-IDP-02)
groups:
- platform-admins # sensitive admin UI: authenticated AND in group (R-IDP-06)
# NO `apiRoutes` HERE, DELIBERATELY. PiHole's admin UI serves no /api/ path, so the `^/api/`
# that jackett and radarr need would match nothing on a good day — and a BROADER guess would
# answer PiHole's own page loads with 401, leaving a logged-out operator on a bare error page
# with no sign-in offered. This app is the reason auth.apiRoutes is per-service (schema 1.9.0)
# rather than a renderer constant: the measurement that justifies the flag on two apps is
# evidence about those two apps. Its oauth2-proxy log holds 0 unauthenticated requests across
# the current pod's whole 23h lifetime (measured 2026-10-01), so there is nothing to act on.
lifecycle:
# LEVEL-TRIGGERED ADLIST ASSERTION, run on EVERY container start. Same pattern, and the same
# reasoning, as catalog/qbittorrent.yaml's postStart: desired state lives in git and is
# RE-ASSERTED unconditionally rather than set once by hand.
#
# WHY THIS IS NEEDED AT ALL — the blocklists were NOT reproducible from git. Measured on live prod
# 2026-10-04: `gravity.db` held exactly ONE adlist (StevenBlack's hosts, enabled) and 72,234
# gravity domains, and NOTHING in this repo named it. Worse, a FRESH database does not seed it:
# the image's own schema template `/etc/.pihole/advanced/Templates/gravity.db.sql` creates the
# `adlist` table and inserts NO rows into it (grepped in the running image — there is no
# `INSERT INTO adlist` at all). So a rebuild from git, or a replica with a new PVC, would come up
# SERVING DNS AND BLOCKING NOTHING, silently and with every object reporting healthy.
#
# WHY A postStart HOOK AND NOT A FILE. PiHole v6 keeps adlists as DB rows, not config. The
# `/etc/pihole/adlists.list` file still exists in `gravity.sh` but is MIGRATION-ONLY: its import
# sits inside `migrate_to_database()`, guarded by `if [ ! -e "$gravityDBfile" ]`, so it fires only
# while CREATING a database and never again on an existing one. A ConfigMap mounted there would
# therefore be silently ignored on every pod this cluster currently runs. There is also no
# `pihole adlist` CLI subcommand (only allow/deny for domains), so SQL against the DB is the
# actual interface. FTL ships an embedded shell (`pihole-FTL sqlite3`, SQLite 3.53.1) so no extra
# tooling is needed in the image.
#
# IT RECONCILES, IT DOES NOT JUST ADD: adlists present in the DB but absent from DECLARED below are
# DISABLED (not deleted — deleting cascades to `gravity` rows and would force a full re-download of
# everything on a typo). So this list is the source of truth in both directions, and `pihole -g`
# runs ONLY when something actually changed, which keeps the steady-state start free of a 70k-domain
# rebuild.
#
# NO BRACED DOLLAR EXPANSION ANYWHERE IN THIS SCRIPT, DELIBERATELY — the same contract
# catalog/qbittorrent.yaml documents at length. apps-wave-0 declares postBuild.substituteFrom
# cluster-vars, so Flux runs envsubst over this whole manifest at reconcile time; a `${VAR}` form
# would behave differently on the cluster than under a plain `kubectl apply` of the golden. Bare
# `$VAR` and `$(cmd)` are untouched by envsubst and so behave identically however this is run.
# `render.py`'s assert_substitution_safe refuses to emit a manifest that breaks this.
postStart:
exec:
command:
- /bin/bash
- -c
- |
# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and
# does NOT run a gravity rebuild.
set -u
GRAVITY=/etc/pihole/gravity.db
# THE DECLARED SET — one URL per line, the source of truth for what PiHole blocks.
# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.
DECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,
# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.
i=0
while [ ! -s "$GRAVITY" ] && [ "$i" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done
if [ ! -s "$GRAVITY" ]; then
echo "pihole-adlists: gravity.db absent after 180s - leaving adlists untouched" >&2
exit 0
fi
sql() { pihole-FTL sqlite3 "$GRAVITY" "$1"; }
CHANGED=0
# 1) Every declared URL must be present AND enabled.
echo "$DECLARED" | while IFS= read -r url; do
[ -n "$url" ] || continue
printf '%s\n' "$url"
done > /tmp/declared.txt
while IFS= read -r url; do
[ -n "$url" ] || continue
# Single quotes inside a URL would break this SQL; adlist URLs do not contain them.
have=$(sql "select count(*) from adlist where address='$url';")
if [ "$have" = "0" ]; then
echo "pihole-adlists: adding $url"
sql "insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');"
CHANGED=1
else
en=$(sql "select enabled from adlist where address='$url';")
if [ "$en" != "1" ]; then
echo "pihole-adlists: re-enabling $url"
sql "update adlist set enabled=1 where address='$url';"
CHANGED=1
fi
fi
done < /tmp/declared.txt
# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).
sql "select address from adlist where enabled=1;" | while IFS= read -r addr; do
[ -n "$addr" ] || continue
if ! grep -qxF "$addr" /tmp/declared.txt; then
echo "pihole-adlists: disabling undeclared $addr"
sql "update adlist set enabled=0 where address='$addr';"
echo changed >> /tmp/adlist-changed
fi
done
[ -s /tmp/adlist-changed ] && CHANGED=1
rm -f /tmp/adlist-changed /tmp/declared.txt
# 3) Rebuild gravity ONLY if the declared set actually moved.
if [ "$CHANGED" = "1" ]; then
echo "pihole-adlists: declared set changed - running gravity update"
pihole -g || echo "pihole-adlists: gravity update failed; existing gravity.db retained" >&2
else
echo "pihole-adlists: already matches the declared set - nothing to do"
fi
exit 0
update:
channel: stable # R-UPD-01
range: ">=2024.0.0"
+221 -8
View File
@@ -72,14 +72,227 @@ env:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
# clients, so per "prefer safety over completeness" it is left unset. FOLLOW-UP: once remote
# reachability through the Gateway is confirmed, set ADVERTISE_IP: "https://plex.home.keithjackson.dev:443".
# ADVERTISE_IP ("Custom server access URLs") — the connection Plex publishes to plex.tv.
#
# THE VALUE MUST BE RESOLVABLE BY EVERY CLIENT, NOT JUST BY CLIENTS THAT USE OUR DNS. That single
# property is what this field keeps getting wrong, so it is the first thing to check before
# changing it. A Plex client resolves the advertised host ITSELF, with whatever resolver it
# happens to use — and on a real LAN that is frequently NOT PiHole: browsers ship DoH on by
# default, phones have "private DNS", guest and IoT devices often take the router's DNS, and some
# clients hard-code a public resolver.
#
# THAT IS NOT THEORETICAL — IT TOOK PLEX DOWN FOR THE WHOLE LAN ON 2026-10-05. This field briefly
# advertised `plex-vip.${BASE_DOMAIN}`, a name that exists ONLY in PiHole's split-horizon answers
# (catalog/pihole.yaml) and in no public zone. Clients that did not use PiHole could not resolve
# it; the SOHO router answered the failed lookup with ITSELF, and every one of them landed on the
# router's own web UI and reported a certificate error for `CN = %D`. Measured 2026-10-05: the
# router serves that self-signed cert (`C = ZZ, O = %D342e7e6b, CN = %D`) on BOTH `192.168.2.1:443`
# and the WAN address, while PiHole itself was answering perfectly — both replicas returned
# `plex-vip -> ${PLEX_L4_VIP}` and `plex -> ${GATEWAY_VIP}`. The DNS was right; the DEPENDENCY on
# it was the bug. Nothing Plex advertises listens on 443, which is how we know the clients had left
# the Plex path entirely and were talking to the router.
#
# SO: `<dashed-ip>.<serverhash>.plex.direct`, WHICH IS RESOLVER-INDEPENDENT. This is Plex's own
# mechanism and the reason it exists: `plex.direct` is a PUBLIC zone that returns the private
# address embedded in the label, so EVERY resolver on earth — PiHole, the router, 1.1.1.1, a DoH
# browser — answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no
# split-horizon entry, no Cloudflare record, and no certificate work, because the wildcard cert
# Plex already serves on 32400 covers exactly this name. Re-verified live 2026-10-05 with FULL
# validation (no `curl -k`): resolves to 192.168.2.24 and `/identity` returns **200, TLS verified**.
#
# WHY NOT THE NICER HOSTNAME, since a readable name in our own zone is obviously more pleasant:
# it cannot be made to work for clients that bypass PiHole. A public record for it would resolve
# to the WAN address, and with no NAT hairpin on this router a LAN client sent there fails too —
# so the hostname is correct ONLY for clients using PiHole, and wrong for the rest. There is no
# configuration of that name that is right for both. `plex.direct` is right for both.
# (The PiHole `plex-vip` answer is KEPT — it is harmless, costs nothing, and is genuinely useful
# for reaching the VIP by name from a LAN shell. It is simply not what Plex advertises.)
#
# THE TWO EMBEDDED VALUES, and the honest cost of this choice. The dashed IP must track
# ${PLEX_L4_VIP} and `<serverhash>` is this server's certificate identity — read it from the live
# listener with
# openssl s_client -connect <vip>:32400 </dev/null | openssl x509 -noout -subject
# Nothing in this tree can derive either, and nothing checks that they still agree: envsubst
# cannot dash an IP, so this is a literal. That fragility is real and was the reason the hostname
# was tried in the first place — it is accepted here because an unresolvable name is a WORSE
# failure (silent, LAN-wide, and it points users at the router) than a stale literal, which at
# least fails loudly and only after someone changes the VIP.
#
# The image's /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections"
# "${ADVERTISE_IP}"` (line 127, verified in the running image), OUTSIDE the
# PLEX_CLAIM/PlexOnlineToken guard, with its `/.firstRunComplete` marker on the container OVERLAY
# fs rather than the /config PVC — so this is re-applied on EVERY pod start and git, not a web
# form, owns the value.
- name: ADVERTISE_IP
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
# THE TLS MATERIAL FOR THE NAME ABOVE, as PEM text in env rather than a mounted volume.
# `plex-vip-tls` is issued by cert-manager from ClusterIssuer `letsencrypt-prod` for exactly
# `plex-vip.${BASE_DOMAIN}` (apps/wave-0/plex-cert.yaml, same wave => same reconcile as this
# workload). The postStart hook converts it to the PKCS#12 bundle Plex requires.
#
# WHY ENV AND NOT A VOLUME, which would be the conventional choice. MEASURED 2026-10-04:
# `schemas/servicedefinition.schema.json` `storage` accepts only
# [class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`,
# `catalog/render.py` emits NO secret volumes, and no rendered app mounts a Secret as a volume.
# Mounting one therefore needs a schema + renderer change; `env.valueFrom.secretKeyRef` is
# already in the schema and already used by PLEX_CLAIM above. PEM is text, so it survives an env
# var intact (a raw PKCS#12 would not — it is binary and carries NUL bytes).
#
# COST, STATED PLAINLY: the private key is readable from this container's environment
# (/proc/1/environ, `kubectl exec env`) by anything that can already exec into the pod. That is
# the same audience that can read the converted bundle under /config, so the marginal exposure is
# small — but a secret VOLUME would still be the better shape, and adding secret-volume support
# to the schema is the clean follow-up that removes this note.
#
# `optional: true` IS LOAD-BEARING, NOT DEFENSIVE CLUTTER (schema 1.11.0, added for this). The
# Certificate is issued INTO THIS NAMESPACE, and the namespace is created by the rendered
# apps/wave-0/plex.yaml itself — so the cert CANNOT be issued in an earlier tier, and the
# Certificate and this Deployment land in the SAME reconcile. On a cold bootstrap the Secret is
# therefore briefly absent while cert-manager completes a DNS01 challenge. With a HARD ref, Plex
# would sit in CreateContainerConfigError until a certificate issued — and because `apps-wave-0`
# is `wait: true` with `apps-wave-1` dependsOn it, a failed or slow issuance would STALL THE WHOLE
# APP TIER (the same hazard the `hardware` block below documents for the GPU resource). Optional
# instead means: no cert -> Plex still starts and serves, the postStart hook logs that the
# certificate was not installed, and the next start after issuance converges. Plex falls back to
# its own `*.plex.direct` certificate in the meantime, so the degraded state is "advertised URL
# not yet servable over TLS", not "media server down".
- name: PLEX_TLS_CRT
valueFrom:
secretKeyRef:
name: plex-vip-tls
key: tls.crt
optional: true
- name: PLEX_TLS_KEY
valueFrom:
secretKeyRef:
name: plex-vip-tls
key: tls.key
optional: true
lifecycle:
# LEVEL-TRIGGERED PLEX SETTINGS, re-asserted on EVERY container start — the same pattern and the
# same reasoning as catalog/pihole.yaml's and catalog/qbittorrent.yaml's postStart: desired state
# lives in git and is re-asserted unconditionally rather than typed into a web form once. Typing
# settings into the web form is precisely what took this service down on 2026-10-04.
#
# IT EXISTS BECAUSE THE IMAGE HAS NO ENV FOR THESE SETTINGS. Measured 2026-10-04 across the whole
# init surface (/etc/cont-init.d, /etc/services.d, /plex-common.sh, /usr/local/bin): the image
# calls setPref for exactly SIX prefs — MachineIdentifier, ProcessedMachineIdentifier,
# PlexOnlineToken, customConnections, allowedNetworks, TranscoderTempDirectory — and supports NO
# generic PLEX_PREFERENCE* hook of any kind. The two things we need (a LAN definition and a custom
# certificate) are reachable ONLY through Plex's own API, so this hook is the only GitOps path.
#
# IT MUST NEVER FAIL. A postStart hook that exits non-zero makes kubelet KILL the container, so
# every step is bounded and the script ends with an unconditional `exit 0`. A missing token, an
# unissued certificate or an unreachable API degrades to "not applied this start" and is retried
# on the next one; it never crashloops Plex.
postStart:
exec:
command:
- /bin/bash
- -c
- |
# NOTE — LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.
# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a
# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD
# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be
# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` — bare. Only
# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be
# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell
# variable written with braces would be blanked or would fail the whole Kustomization —
# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and
# catalog/render.py now refuses to emit it (which is how this very comment got caught).
# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the
# certificate has not issued yet they are genuinely UNSET, not empty — and under `set -u`
# the first `[ -n "$PLEX_TLS_CRT" ]` would abort the script, which for a postStart hook
# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists
# to prevent, so turning it back on here would defeat the whole design. (The alternative,
# a brace-wrapped `:-` default, would need envsubst escaping — the trap the note above
# describes.) Every value below is explicitly guarded instead.
PX="/config/Library/Application Support/Plex Media Server/Preferences.xml"
CERTDIR=/config/certs
P12=$CERTDIR/plex-vip.p12
# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it
# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a
# container-format requirement, not an access control.
P12PASS=plexvip
LANNETS='10.0.0.0/8,192.168.0.0/16'
CERTDOMAIN=plex-vip.${BASE_DOMAIN}
API=http://127.0.0.1:32400
# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so
# a cert-manager RENEWAL is picked up with no extra moving part.
if [ -n "$PLEX_TLS_CRT" ] && [ -n "$PLEX_TLS_KEY" ]; then
mkdir -p $CERTDIR && chmod 700 $CERTDIR
printf '%s' "$PLEX_TLS_CRT" > $CERTDIR/tls.crt
printf '%s' "$PLEX_TLS_KEY" > $CERTDIR/tls.key
chmod 600 $CERTDIR/tls.key
if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \
-in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then
mv -f $P12.tmp $P12
chown 1000:1000 $P12 2>/dev/null || true
chmod 600 $P12
echo "[plex-settings] PKCS#12 bundle written"
else
rm -f $P12.tmp
echo "[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle" >&2
fi
rm -f $CERTDIR/tls.key
else
echo "[plex-settings] WARN: TLS env empty; certificate not installed this start" >&2
fi
# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.
i=0
while [ "$i" -lt 45 ]; do
if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)" = "200" ]; then
break
fi
i=$(expr $i + 1); sleep 2
done
# (3) The prefs API needs the server token. It already lives in Preferences.xml on this
# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An
# UNCLAIMED server has none — a normal state on a fresh /config — so skip, do not fail.
TOKEN=$(grep -o 'PlexOnlineToken="[^"]*"' "$PX" 2>/dev/null | head -1 | cut -d'"' -f2)
if [ -z "$TOKEN" ]; then
echo "[plex-settings] not claimed yet — skipping prefs, will retry next start" >&2
exit 0
fi
# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on
# a PUT, so a raw '/' or ',' would be read as structure rather than data.
enc() {
printf '%s' "$1" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \
-e 's|:|%3A|g' -e 's| |%20|g'
}
setpref() {
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT \
-H "X-Plex-Token: $TOKEN" "$API/:/prefs?$1=$(enc "$2")" 2>/dev/null)
if [ "$code" = "200" ]; then
echo "[plex-settings] set $1"
else
echo "[plex-settings] WARN: $1 -> HTTP $code" >&2
fi
}
# (4) THE LAN DEFINITION — the reason this hook was written. Without it Plex has NO notion
# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests
# logged "Allowed Network (WAN)" and ZERO "(LAN)", including Seerr from inside the
# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks
# (the ALLOWED_NETWORKS env) only waives AUTH — it does NOT make a client local. Two
# different settings; only one of them has an env hook.
setpref LanNetworksBandwidth "$LANNETS"
# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only
# if the bundle exists — pointing Plex at a missing file would break its TLS listener.
if [ -s "$P12" ]; then
setpref customCertificatePath "$P12"
setpref customCertificateKey "$P12PASS"
setpref customCertificateDomain "$CERTDOMAIN"
else
echo "[plex-settings] WARN: no bundle; leaving certificate prefs untouched" >&2
fi
exit 0
# HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02).
# INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
+6
View File
@@ -448,6 +448,12 @@ auth:
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
- platform-admins # operators of these services must not be locked out of them
# NO `apiRoutes` HERE: UNMEASURED, not cleared. qBittorrent's WebUI drives everything through
# /api/v2/, so it is plausibly affected in the same way jackett is — but plausibility is what
# put a renderer constant on the table in the first place. MEASURED 2026-10-01 on the live pod
# (qbittorrent-5547bdb9bd-zq557, 0 restarts, 23h): 13,649 log lines, ZERO unauthenticated
# requests, nothing but health probes. No traffic means no evidence either way. Add `^/api/`
# when someone opens the WebUI and records the 302/401 split, as was done for jackett.
update:
channel: stable # R-UPD-01
range: ">=5.0.0 <6.0.0" # stay on the qBittorrent 5.x stable line
+29
View File
@@ -100,6 +100,9 @@ dependencies:
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
port: 9117
protocol: tcp
- to: plex
port: 32400 # arr -> plex: required since library is on NFS which does not provide file CRUD notifications
protocol: tcp
ingressRoutes:
- host: radarr.${BASE_DOMAIN}
port: 7878 # Radarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
@@ -112,6 +115,32 @@ auth:
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
- platform-admins # operators of these services must not be locked out of them
# Answer Radarr's machine endpoints with 401 instead of a 302 to Keycloak (schema 1.9.0;
# renders --api-route, singular and repeatable, onto the oauth2-proxy sidecar).
#
# `^/signalr/` — MEASURED ON THE LIVE POD, 2026-10-01, 23h after the cookie fix deployed
# (radarr-7d55cbffb9-42hmh, 0 restarts): 232 unauthenticated requests in the pod's whole
# lifetime and ALL 232 are the same call — `POST /signalr/messages/negotiate` from a browser
# UA, answered 302 by the proxy with upstream `-`, i.e. Radarr never saw one of them. That is
# a left-open tab whose session the cookie rename orphaned, polling SignalR forever, silently
# redirected every time and NEVER re-prompting the operator. It is the reported symptom
# ("reloading never fixes it and I am never re-prompted") still running in production on a
# service nobody was watching. With 401 the UI's connection breaks LOUDLY, the operator
# reloads, and `/` is outside both regexes so the reload signs them back in.
#
# `^/api/` — measured on radarr, but on the PRE-FIX pod and NOT by me: the 2026-09-30
# diagnosis (decisions/2026-09-30-jackett-oauth2-proxy-session.md) recorded radarr's 156
# unauthenticated requests as `/api/v3/command` (62), `/api/v3/queue/details` (46) and
# `/signalr/messages/negotiate` (43). The current pod has logged ZERO /api/ traffic, so I did
# not re-measure it; it is included because 108 of 156 measured rejections on this app were
# /api/v3 XHRs, which is this app's own evidence rather than an analogy to another app.
#
# SCOPE: both are JSON/RPC namespaces a browser never NAVIGATES to. Radarr's UI is served at
# `/` with assets under /Content/ and /MediaCover/, none of which match — so a plain page load
# is still 302'd into a silent SSO sign-in.
apiRoutes:
- ^/api/
- ^/signalr/
update:
channel: stable # R-UPD-01
range: ">=5.0.0 <6.0.0" # stay on the Radarr v5 stable line
+580 -37
View File
@@ -156,11 +156,17 @@ OAUTH2_COOKIE_REFRESH = "2m30s"
# ready endpoints. The flag is confirmed present in the PINNED v7.8.1 binary (`--help`),
# which matters because the shared proxy is on v7.15.3 and could not have vouched for it.
#
# THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED. A container port is not a
# Service port: these sidecars' Services expose the app port and :4180, and each namespace's
# default-deny baseline has `ingress: []`, so Prometheus can neither resolve nor reach this
# port until a Service port (or PodMonitor), a monitoring-ingress allow and a ServiceMonitor
# are added. Do not read this flag as "metrics are working".
# THE SCRAPE WORKS AS OF 2026-10-05 — but it took THREE independent pieces, and this flag is
# only the first. A container port is not a Service port, and each namespace's default-deny
# baseline denies all ingress, so the flag alone bought nothing for 63.7h:
# 1. this flag + a named `metrics` container port (PR #28)
# 2. a monitor that targets POD ports, not Service ports — platform/oauth2-proxy/podmonitor.yaml
# is a PodMonitor for exactly that reason (no Service exposes 44180)
# 3. an ingress allowance from ns monitoring (PR #49, r_monitoring_ingress_policy below)
# Verified on prod 2026-10-05: all 6 targets up == 1, scraping in 1-6ms.
# STILL do not read this flag alone as "metrics are working" — 1 without 3 is precisely the state
# that produced 5 x TargetDown for 63.7h. If you add a sidecar metrics port anywhere else, check
# that r_monitoring_ingress_policy covers it.
OAUTH2_PROXY_METRICS_PORT = 44180
# --- Hardware capability: `hardware.gpu: intel-quicksync` ------------------------------
@@ -175,6 +181,45 @@ OAUTH2_PROXY_METRICS_PORT = 44180
# prod nodes 2026-10-01: `.status.allocatable["gpu.intel.com/i915"] == 1`.
QUICKSYNC_RESOURCE = "gpu.intel.com/i915"
# --- API routes: 401 instead of 302, PER APP ------------------------------------------
# oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. Its built-in AJAX
# guard would send a 401 instead, but it fires only on an EXACT `Accept: application/json`,
# which these apps' JavaScript does not consistently send. So an unauthenticated XHR gets a
# 302, the browser follows it INVISIBLY, the live Keycloak SSO session satisfies it silently,
# and the call resolves 200 carrying the app's HTML where the JavaScript expected JSON — no
# prompt, no console error, nothing logged as a failure. 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, twice:
# jackett 2026-09-30: 33 x 302 against 2 x 401 on /api/ paths, and 4 of 6 POSTs answered by
# the proxy and never reaching the app (access-log upstream field `-`, not `/`).
# radarr 2026-10-01, 23h after the cookie fix rolled: 232 x 302, EVERY ONE a
# `POST /signalr/messages/negotiate` from the operator's own browser, upstream `-`.
# A left-open tab whose cookie the rename orphaned, polling forever, silently
# redirected every time, NEVER re-prompting. That is the reported symptom
# ("reloading never fixes it and I am never re-prompted") still happening in
# production, on a service nobody was looking at.
# `--api-route=<regex>` makes matching paths answer 401.
#
# WHY THIS IS NOT A RENDERER CONSTANT, AND MUST NEVER BECOME ONE. The path shape is a property
# of the APP, not of the forward-auth pattern: radarr's broken path is `/signalr/`, jackett's is
# `/api/`, and PIHOLE SERVES NO `/api/` AT ALL. One constant would apply one app's path shape to
# five, and the failure mode of a wrong one is not "no improvement" but "this service is
# permanently broken": a path a BROWSER NAVIGATES to, answered 401, shows a logged-out operator
# a bare error page and never offers a sign-in. So the routes are declared per service in
# `auth.apiRoutes` (schema 1.9.0) and rendered one `--api-route` per entry — the flag is
# repeatable, and it is SINGULAR: `--api-routes` does not exist in v7.8.1 and the proxy refuses
# to start with it (verified against the pinned binary's own --help, 2026-10-01:
# `--api-route strings return HTTP 401 instead of redirecting to authentication server...`).
#
# BREADTH RATCHET. Every entry must be anchored at `^/` and continue with literal path
# characters. That rejects `^/`, `^/.*`, `.*`, an unanchored `/api/`, and any alternation — the
# shapes that turn a page load into a 401. Alternation is refused rather than supported because
# the flag is repeatable, so one regex per route costs nothing and stays readable. The same
# pattern is in the schema (so ci/l0.sh's schema stage rejects it at the authoring surface) and
# enforced HERE (so ci/render-golden.sh fails even if the schema stage is skipped) — a breadth
# mistake must not need two independent gates to both be running.
OAUTH2_API_ROUTE_PATTERN = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$")
# =============================================================================
# Defaults (renderer-contract.md "Default posture") — materialize BEFORE mapping.
@@ -200,6 +245,12 @@ def apply_defaults(sd: dict) -> dict:
if "mode" not in auth:
auth["mode"] = "forward-auth" if s["ingressRoutes"] else "none"
auth.setdefault("groups", [])
# apiRoutes default posture is EMPTY, deliberately, and that is the whole point of the field
# (schema 1.9.0): an app gets --api-route only where someone MEASURED that it serves a machine
# API, so a newly-onboarded app renders no --api-route at all rather than inheriting another
# app's path shape. See OAUTH2_API_ROUTE_PATTERN above for why a default of `^/api/` would be
# wrong rather than merely generous.
auth.setdefault("apiRoutes", [])
s["auth"] = auth
update = dict(s.get("update") or {})
@@ -227,7 +278,15 @@ def apply_defaults(sd: dict) -> dict:
# object that actually runs on the cluster is not the one this SD renders (keycloak: the
# Keycloak operator runs a StatefulSet, so a Deployment-shaped rule would `absent()` forever).
lw = dict(obs.get("livenessWorkload") or {})
lw.setdefault("kind", "VirtualMachineInstance" if s["kind"] == "vm" else "Deployment")
# The default must track what is ACTUALLY rendered, or the `up` SLI silently dies: a
# Deployment-shaped rule against a StatefulSet `absent()`s forever, which is an alert that can
# never fire — the exact failure mode the keycloak override was added for. Deriving it from
# `availability.replicas` means bumping the replica count cannot leave a stale rule behind.
lw.setdefault(
"kind",
"VirtualMachineInstance" if s["kind"] == "vm"
else ("StatefulSet" if _is_statefulset(s) else "Deployment"),
)
lw.setdefault("name", s["name"])
obs["livenessWorkload"] = lw
s["observability"] = obs
@@ -430,6 +489,37 @@ def r_bulk_pvs(s: dict) -> list:
return out
def _replicas(s: dict) -> int:
"""Declared replica count (schema 1.10.0 `availability.replicas`). Default 1."""
return int((s.get("availability") or {}).get("replicas", 1) or 1)
def _is_statefulset(s: dict) -> bool:
"""True when this container workload must render a StatefulSet rather than a Deployment.
THE DECIDING CONSTRAINT IS THE VOLUME, not a preference. A longhorn storage entry renders
ReadWriteOnce (r_pvcs), and one RWO PVC cannot be attached by pods on two different nodes --
which is exactly what `availability.replicas > 1` plus the node anti-affinity below asks for.
A 2-replica Deployment over a shared RWO claim does not degrade, it simply never schedules the
second pod. A StatefulSet's volumeClaimTemplates give each replica its OWN claim, which is the
only shape that satisfies both at once.
A replicas-1 service keeps rendering a Deployment so every pre-existing golden is byte-identical.
"""
return s["kind"] == "container" and _replicas(s) > 1
def _sts_claim_volumes(s: dict) -> list:
"""Storage entries that become per-replica volumeClaimTemplates on a StatefulSet.
Only the dynamically-provisioned (longhorn) entries. A `bulk` entry is a STATIC, pre-bound NFS
PV over data that already exists outside the cluster and is shared read-only by every consumer
(r_bulk_pvs) -- per-replica copies of it are neither possible nor wanted, so it stays a
standalone PVC and an ordinary pod volume.
"""
return [v for v in s["storage"] if v.get("class", "longhorn") != "bulk"]
def r_pvcs(s: dict) -> list:
"""One PVC per storage entry (R-STATE-01/02/04).
@@ -437,8 +527,15 @@ def r_pvcs(s: dict) -> list:
bulk -> the STATIC external NFS PV rendered by r_bulk_pvs, pre-bound by volumeName.
"""
out = []
# On a StatefulSet the longhorn claims are NOT standalone objects — they are
# volumeClaimTemplates, so each replica gets its own (see _is_statefulset). Emitting both would
# leave an orphan PVC that nothing mounts and that `prune` would later delete out from under
# nobody. `bulk` is unaffected and still rendered here.
sts = _is_statefulset(s)
for vol in s["storage"]:
cls = vol.get("class", "longhorn")
if sts and cls != "bulk":
continue
if cls == "bulk":
# Mirror image of the PV: the same explicitly-empty class (so nothing dynamically
# provisions), the same access mode, and volumeName completing the two-sided binding.
@@ -519,6 +616,12 @@ def r_deployment(s: dict) -> dict:
if vol.get("readOnly"):
mount["readOnly"] = True
volume_mounts.append(mount)
# On a StatefulSet a longhorn volume is supplied by its volumeClaimTemplate, whose
# metadata.name IS the pod volume name. Adding it to pod.volumes as well is a conflict
# (duplicate volume name), so it is skipped here and emitted below. The MOUNT is unchanged —
# the container cannot tell the difference, which is what keeps this transparent to the app.
if _is_statefulset(s) and vol.get("class", "longhorn") != "bulk":
continue
volumes.append(
{
"name": vol["name"],
@@ -616,20 +719,80 @@ def r_deployment(s: dict) -> dict:
if ann:
tmpl_meta["annotations"] = ann
spec = {"replicas": 1}
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
if s["storage"]:
sts = _is_statefulset(s)
# NODE ANTI-AFFINITY, and it is REQUIRED rather than preferred. `availability.replicas > 1`
# exists to survive the loss of one node; `preferred` would let both replicas land on the same
# node and quietly deliver none of that, with every object reporting healthy. The cost is real
# and is accepted: with N replicas the workload needs N schedulable nodes, so on a 3-node
# cluster losing TWO nodes leaves a replica Pending and — because `apps-wave-0` is `wait: true`
# with `apps-wave-1` dependsOn it — STALLS THE APP TIER. That coincides with having already lost
# etcd quorum on a 3-node control plane, i.e. the cluster is not reconciling anything at that
# point anyway, so the stall is a symptom rather than a new failure.
if sts:
pod_spec["affinity"] = {
"podAntiAffinity": {
"requiredDuringSchedulingIgnoredDuringExecution": [
{
"labelSelector": {
"matchLabels": {"app.kubernetes.io/name": s["name"]}
},
"topologyKey": "kubernetes.io/hostname",
}
]
}
}
spec = {"replicas": _replicas(s)}
if sts:
# serviceName is a required StatefulSet field. It points at the service rendered by
# r_service; that Service is a normal ClusterIP rather than headless, so per-pod DNS
# (<pod>.<svc>) does NOT resolve — and nothing here needs it. These replicas are
# interchangeable resolvers behind one VIP, not peers that address each other.
spec["serviceName"] = s["name"]
# Parallel, not the OrderedReady default. OrderedReady starts replica N+1 only after N is
# Ready, so a cold start costs the sum of every replica's first-boot work instead of the
# max — and `apps-wave-0` has `timeout: 5m`. There is no ordering requirement between
# replicas to protect (no leader, no migration, no shared volume), so serialising buys
# nothing and risks the wave timing out on a rebuild.
spec["podManagementPolicy"] = "Parallel"
elif s["storage"]:
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
#
# A StatefulSet needs none of this and cannot express it: each replica owns its own claim,
# so a rolling update hands nothing over, and `strategy` is not a StatefulSet field at all.
spec["strategy"] = {"type": "Recreate"}
spec["selector"] = {"matchLabels": {"app.kubernetes.io/name": s["name"]}}
spec["template"] = {"metadata": tmpl_meta, "spec": pod_spec}
if sts:
# Per-replica claims. metadata.name MUST equal the pod volume name the container mounts
# (see the volumes loop above); the resulting PVCs are <name>-<sts>-<ordinal>, e.g.
# config-pihole-0. Same tier and access mode a standalone longhorn PVC gets from r_pvcs —
# this changes WHO owns the claim, never what it is.
tmpls = []
for vol in _sts_claim_volumes(s):
vspec = {"accessModes": ["ReadWriteOnce"], "storageClassName": "longhorn"}
if vol.get("size"):
vspec["resources"] = {"requests": {"storage": vol["size"]}}
tmpls.append(
{
"metadata": {
"name": vol["name"],
"labels": {"app.kubernetes.io/name": s["name"]},
},
"spec": vspec,
}
)
if tmpls:
spec["volumeClaimTemplates"] = tmpls
return {
"apiVersion": "apps/v1",
"kind": "Deployment",
"kind": "StatefulSet" if sts else "Deployment",
"metadata": {
"name": s["name"],
"namespace": s["name"],
@@ -639,6 +802,35 @@ def r_deployment(s: dict) -> dict:
}
def r_pdb(s: dict):
"""PodDisruptionBudget for a multi-replica workload (availability.replicas > 1).
minAvailable 1, not `maxUnavailable: 1`. The budget's job here is to stop a VOLUNTARY
disruption — a node drain for maintenance, a cluster upgrade — from taking the last replica
with it; `minAvailable: 1` says that whatever else happens, one must keep serving. It does
NOT protect against involuntary disruption (a node dying), which is what the anti-affinity
and the replica count are for.
Rendered only when replicas > 1: a PDB over a single-replica workload is actively harmful, as
`minAvailable: 1` would then block every drain of its node forever.
"""
if not _is_statefulset(s):
return None
return {
"apiVersion": "policy/v1",
"kind": "PodDisruptionBudget",
"metadata": {
"name": s["name"],
"namespace": s["name"],
"labels": {"app.kubernetes.io/name": s["name"]},
},
"spec": {
"minAvailable": 1,
"selector": {"matchLabels": {"app.kubernetes.io/name": s["name"]}},
},
}
def r_service(s: dict):
ports = _service_ports(s)
# A declared metrics endpoint becomes a NAMED Service port, because that name is the only
@@ -1075,7 +1267,12 @@ def _oauth2_sidecar_container(s: dict) -> dict:
--cookie-name and --cookie-domain are DERIVED per app (see the block comment on those two
args): every instance holds a different cookie-secret, so sharing one cookie name across the
zone makes the instances evict each other's sessions."""
zone makes the instances evict each other's sessions.
--api-route is DECLARED per app, from auth.apiRoutes, and is the one flag here that is
neither a constant nor derivable: it names the paths THIS app serves as a machine API, and
those differ per app (radarr `/signalr/`, jackett `/api/`, pihole none). An app that declares
none renders no --api-route at all."""
route = s["ingressRoutes"][0]
host = route["host"]
upstream_port = route["port"]
@@ -1095,6 +1292,27 @@ def _oauth2_sidecar_container(s: dict) -> dict:
args += [
"--skip-provider-button=true",
f"--redirect-url=https://{host}/oauth2/callback",
]
# ── API ROUTES: 401 instead of 302, ON THIS APP'S OWN PATHS ONLY ───────────────────────
# One flag per declared route, in declared order. An app that declares NONE renders NO
# --api-route, which is the correct and common case — see OAUTH2_API_ROUTE_PATTERN above for
# the measurements, for why this cannot be a renderer constant, and for the breadth ratchet.
# Validated here as well as in the schema so a breadth mistake is caught by render-golden
# even on a run where the schema stage did not execute.
for route in s["auth"].get("apiRoutes", []):
if not OAUTH2_API_ROUTE_PATTERN.match(str(route)):
raise SystemExit(
f"render.py: {s['name']}: auth.apiRoutes entry {route!r} is not an anchored path "
f"regex. Each entry must start with '^/' and continue with literal path "
f"characters (e.g. '^/api/', '^/signalr/', '^/api/v3/.*'). Rejected shapes: '^/' "
f"and '^/.*' (match every page load), an unanchored '/api/' (matches mid-path), "
f"and alternation (the flag is repeatable — declare one entry per route). A "
f"too-broad --api-route answers a PAGE LOAD with 401, so a logged-out operator "
f"sees a bare error page and is never offered a sign-in: the service looks "
f"permanently broken rather than merely unauthenticated."
)
args.append(f"--api-route={route}")
args += [
# ── COOKIE ISOLATION (fleet fix 2026-09-30; crafty's 2026-09-29 fix, generalized) ──────
# EVERY oauth2-proxy on this platform — this sidecar, its four siblings, and the SHARED
# forward-auth proxy (platform/oauth2-proxy) — is an INDEPENDENT instance holding its OWN
@@ -1150,8 +1368,9 @@ def _oauth2_sidecar_container(s: dict) -> dict:
f"--cookie-refresh={OAUTH2_COOKIE_REFRESH}",
"--pass-access-token=true",
"--set-xauthrequest=true",
# Start the /metrics listener. Prerequisite only — not yet reachable by Prometheus;
# see OAUTH2_PROXY_METRICS_PORT above for exactly what is still missing.
# Start the /metrics listener. Scraped as of 2026-10-05, but only because
# r_monitoring_ingress_policy also opens this port to ns monitoring — the flag alone was
# inert for 63.7h. See OAUTH2_PROXY_METRICS_PORT above for the three pieces required.
f"--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}",
]
secret = f"{s['name']}-oauth2-proxy"
@@ -1329,12 +1548,54 @@ def r_monitoring_ingress_policy(s: dict):
trap Longhorn's own NetworkPolicies set (platform/longhorn/networkpolicy-metrics.yaml exists
for exactly this reason), and it is a THIRD independent way for a metrics SLI to be inert.
Rendered only alongside a declared metricsEndpoint, and it opens ONLY that port to ONLY the
monitoring namespace: an allow for a port nothing serves would be a standing hole for no
Rendered only alongside a port something actually serves, and it opens ONLY that port to ONLY
the monitoring namespace: an allow for a port nothing serves would be a standing hole for no
benefit.
TWO INDEPENDENT SOURCES OF A SERVED METRICS PORT, and this used to cover only the first:
1. `observability.metricsEndpoint` — the APP's own exporter. NOTE, measured 2026-10-04: no
ServiceDefinition in this catalog declares one, so before the sidecar clause below this
function had NEVER emitted a single object on any branch. It is correct but, so far,
entirely latent — do not read its presence as evidence that scrape ingress is solved.
2. THE forward-auth GATE SIDECAR's own `/metrics` on 44180 (added 2026-10-04). Every
`_needs_oauth2_sidecar` app gets `--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}`
and a named `metrics` container port from `_oauth2_sidecar_container`, so the port is
served whenever the sidecar exists — unconditionally, which is what makes it safe to
open without a per-app declaration.
WHY CLAUSE 2 KEYS ON `_needs_oauth2_sidecar` AND NOT ON `observability.metrics`: it must match
the condition the SCRAPE keys on, exactly. platform/oauth2-proxy/podmonitor.yaml selects pods
by the `homelab.keithjackson.dev/auth-mode: forward-auth` POD LABEL and nothing else, so a
target exists iff the sidecar exists. Keying the allowance on `observability.metrics` instead
would let `metrics: false` produce a discovered-but-blocked target — the same "two halves
authored apart" failure this function exists to prevent, just moved one field over.
THIS CLAUSE IS A BUG FIX, and the bug was live for 63.7h. Measured on prod 2026-10-04: all 6
sidecar targets across jackett/radarr/sonarr/qbittorrent/pihole read `health=down`,
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly 10.0s — the scrapeTimeout,
i.e. packets DROPPED, not refused. Each app namespace ships a `default-deny` NetworkPolicy
with `policyTypes: [Ingress, Egress]` and NO `ingress` key at all (deny-all-ingress), and the
only ingress allowance is `<app>-allow-gateway-ingress`: `fromEntities: [ingress]` -> 4180.
Prometheus matched no clause. 5 x `TargetDown` + 6 x `OAuth2ProxyGateUnobserved` had been
re-notifying at ~22 emails/day since the PodMonitor landed on 2026-10-02.
WHY THE POLICY BELONGS HERE AND NOT IN platform/oauth2-proxy: podmonitor.yaml's header records
the objection — a policy in that tier would name app namespaces created by the SIBLING
apps-wave-0/1 tiers (measured Flux graph: `oauth2-proxy` and `apps-wave-0` both
`dependsOn=[platform-config]`, no ordering between them), so it would apply on a running
cluster and fail on a cold bootstrap. Rendered here it ships in the same tier that creates the
namespace, which dissolves that ordering problem entirely.
"""
ep = s["observability"].get("metricsEndpoint")
if not ep or not s["observability"]["metrics"]:
obs = s["observability"]
ep = obs.get("metricsEndpoint")
ports: list[str] = []
if ep and obs["metrics"]:
ports.append(str(ep["port"]))
if _needs_oauth2_sidecar(s):
ports.append(str(OAUTH2_PROXY_METRICS_PORT))
if not ports:
return None
return {
"apiVersion": "cilium.io/v2",
@@ -1352,7 +1613,7 @@ def r_monitoring_ingress_policy(s: dict):
{"matchLabels": {"k8s:io.kubernetes.pod.namespace": MONITORING_NS}}
],
"toPorts": [
{"ports": [{"port": str(ep["port"]), "protocol": "TCP"}]}
{"ports": [{"port": p, "protocol": "TCP"} for p in ports]}
],
}
],
@@ -1746,7 +2007,13 @@ def render(sd: dict, image_automation: bool = True, inbound: list | None = None)
)
for pvc in r_pvcs(s):
docs.append((f"PVC {pvc['metadata']['name']} (R-STATE)", pvc))
docs.append(("Deployment (R-PLAT-02)", r_deployment(s)))
wl = r_deployment(s)
docs.append((f"{wl['kind']} (R-PLAT-02)", wl))
pdb = r_pdb(s)
if pdb:
docs.append(
("PodDisruptionBudget — keep one replica through a drain (availability.replicas)", pdb)
)
svc = r_service(s)
if svc:
docs.append(("Service", svc))
@@ -1931,6 +2198,24 @@ EXTRA_UNITS: dict = {
"pihole-dns.yaml",
],
},
# Plex's remote-access exposure: the raw tcp/32400 LoadBalancer Service on a pinned VIP, plus the
# CiliumNetworkPolicy that admits off-cluster clients to it (apps/wave-0/plex-remote.yaml).
# `above: False` co-locates it in plex's OWN wave — these are the plex pod's own Service and its
# own policy, applied in the same wave as the Deployment they select, exactly as the rendered
# Service and CNPs in plex.yaml are. Nothing in a higher wave gates on it.
#
# It is a non-catalog unit for the same reason pihole-dns is: the ServiceDefinition schema cannot
# express a LoadBalancer Service or a pinned lbipam VIP (additionalProperties is false, and
# `ingressRoutes` only renders HTTPRoutes on the L7 Gateway, which cannot carry Plex's raw port).
# Plex's own settings stay in the catalog as env on catalog/plex.yaml; the only thing living
# outside it is the exposure the schema has no word for.
"plex-remote": {
"anchor": "plex",
"above": False,
"files": [
"plex-remote.yaml",
],
},
}
@@ -2177,6 +2462,132 @@ def _selftest() -> int:
check("Namespace" in kinds, "container: missing Namespace")
check("NetworkPolicy" in kinds, "container: missing default-deny NetworkPolicy")
check("Deployment" in kinds, "container: missing Deployment")
check(
"PodDisruptionBudget" not in kinds,
"replicas-1 container must render NO PodDisruptionBudget (minAvailable 1 over a single "
"replica blocks every drain of its node forever)",
)
# 1b) availability.replicas (schema 1.10.0). The DEFAULT path must be untouched, and the >1
# path must change workload kind, claim ownership, spreading, budget AND the SLI metric
# together — each of these has a silent-failure mode if it is the one that gets forgotten.
base_av = {
"name": "av",
"source": {"image": "nginx:1"},
"storage": [{"name": "config", "size": "1Gi"}],
"observability": {"metrics": True, "slis": ["up"]},
}
one = render({**base_av, "availability": {"replicas": 1}})
one_kinds = [d[1]["kind"] for d in one]
check(
"Deployment" in one_kinds and "StatefulSet" not in one_kinds,
"availability.replicas=1 must still render a Deployment (back-compat)",
)
check(
"PersistentVolumeClaim" in one_kinds,
"availability.replicas=1 must still render a standalone PVC",
)
check(
"PodDisruptionBudget" not in one_kinds,
"availability.replicas=1 must render no PodDisruptionBudget",
)
many = render({**base_av, "availability": {"replicas": 2}})
many_kinds = [d[1]["kind"] for d in many]
check(
"StatefulSet" in many_kinds and "Deployment" not in many_kinds,
"availability.replicas>1 must render a StatefulSet, not a Deployment (a shared RWO claim "
"cannot be attached on two nodes, so a multi-replica Deployment never schedules)",
)
check(
"PersistentVolumeClaim" not in many_kinds,
"availability.replicas>1 must NOT also render a standalone longhorn PVC — it would be an "
"orphan that nothing mounts",
)
sts = next(d[1] for d in many if d[1]["kind"] == "StatefulSet")
check(sts["spec"].get("replicas") == 2, "StatefulSet must carry the declared replica count")
check(
"strategy" not in sts["spec"],
"StatefulSet must not carry `strategy` (not a StatefulSet field; Recreate is a "
"Deployment-only workaround for handing over one RWO volume)",
)
check(
sts["spec"].get("podManagementPolicy") == "Parallel",
"StatefulSet must set podManagementPolicy=Parallel — OrderedReady serialises first-boot "
"work across replicas and apps-wave-0 has a 5m timeout",
)
check(sts["spec"].get("serviceName") == "av", "StatefulSet must set serviceName")
tmpls = sts["spec"].get("volumeClaimTemplates") or []
check(
[t["metadata"]["name"] for t in tmpls] == ["config"],
"StatefulSet volumeClaimTemplate name must equal the pod volume name the container mounts, "
"or the pod gets a duplicate/unresolvable volume",
)
check(
tmpls and tmpls[0]["spec"]["accessModes"] == ["ReadWriteOnce"]
and tmpls[0]["spec"]["storageClassName"] == "longhorn",
"StatefulSet claim template must keep the same tier/access mode as the standalone PVC",
)
sts_vols = {v["name"] for v in sts["spec"]["template"]["spec"].get("volumes", [])}
check(
"config" not in sts_vols,
"a claim-template volume must NOT also appear in pod.volumes (duplicate volume name)",
)
mounts = sts["spec"]["template"]["spec"]["containers"][0].get("volumeMounts") or []
check(
any(m["name"] == "config" for m in mounts),
"the container must still mount the volume by name on a StatefulSet (transparent to the app)",
)
aff = (sts["spec"]["template"]["spec"].get("affinity") or {}).get("podAntiAffinity") or {}
req = aff.get("requiredDuringSchedulingIgnoredDuringExecution") or []
check(
req and req[0].get("topologyKey") == "kubernetes.io/hostname",
"availability.replicas>1 must render REQUIRED per-node anti-affinity — `preferred` can put "
"every replica on one node and deliver none of the intended node-failure tolerance",
)
pdbs = [d[1] for d in many if d[1]["kind"] == "PodDisruptionBudget"]
check(len(pdbs) == 1, "availability.replicas>1 must render exactly one PodDisruptionBudget")
check(
pdbs and pdbs[0]["spec"].get("minAvailable") == 1,
"PodDisruptionBudget must be minAvailable=1 (keep one replica serving through a drain)",
)
pr_many = next((d[1] for d in many if d[1]["kind"] == "PrometheusRule"), None)
check(
pr_many is not None
and "kube_statefulset_status_replicas_ready"
in pr_many["spec"]["groups"][0]["rules"][0]["expr"],
"the up SLI must follow the rendered workload kind — a Deployment-shaped rule against a "
"StatefulSet absent()s forever and can never fire",
)
# A bulk volume is shared external NFS: it must stay a standalone PVC even on a StatefulSet.
mixed = render(
{
**base_av,
"availability": {"replicas": 2},
"storage": [
{"name": "config", "size": "1Gi"},
{"name": "media", "class": "bulk", "export": "/mnt/x", "size": "1Ti"},
],
}
)
m_sts = next(d[1] for d in mixed if d[1]["kind"] == "StatefulSet")
check(
[t["metadata"]["name"] for t in m_sts["spec"]["volumeClaimTemplates"]] == ["config"],
"only longhorn volumes become claim templates; a bulk share must not be per-replica",
)
check(
any(
d[1]["kind"] == "PersistentVolumeClaim"
and d[1]["metadata"]["name"] == "av-media"
for d in mixed
),
"a bulk PVC must still be rendered standalone on a StatefulSet",
)
check(
"media" in {v["name"] for v in m_sts["spec"]["template"]["spec"].get("volumes", [])},
"a bulk volume must still appear in pod.volumes on a StatefulSet",
)
ns = next(d[1] for d in docs if d[1]["kind"] == "Namespace")
check(
@@ -2732,15 +3143,27 @@ def _selftest() -> int:
"auth": {"mode": "forward-auth", "groups": ["media-users"]},
}
)
fa_dep = next(d[1] for d in fa_docs if d[1]["kind"] == "Deployment")
fa_containers = fa_dep["spec"]["template"]["spec"]["containers"]
fa_dep = next((d[1] for d in fa_docs if d[1]["kind"] == "Deployment"), None)
check(fa_dep is not None,
"a forward-auth app rendered NO Deployment, so there is no pod to gate at all")
fa_containers = (((fa_dep or {}).get("spec") or {}).get("template") or {}) \
.get("spec", {}).get("containers") or []
check(
len(fa_containers) == 2
and fa_containers[0]["name"] == "fa"
and fa_containers[1]["name"] == "oauth2-proxy",
"forward-auth app must render the app container FIRST + an oauth2-proxy sidecar SECOND",
f"forward-auth app must render the app container FIRST + an oauth2-proxy sidecar SECOND; "
f"rendered {[c.get('name') for c in fa_containers]!r}",
)
side = fa_containers[1]
# EVERY assertion below reads `side`, so an absent sidecar must NOT be allowed to raise here.
# The whole point of the None-tolerant lookups in this block is that a missing sidecar (or a
# missing flag on it) reports as NAMED ASSERTIONS rather than as a traceback: an exception
# escaping _selftest() exits non-zero with the right verdict and ZERO diagnostics, and it
# hides every other assertion the same mutation broke. Measured by Desmond Oyelaran while
# wiring this selftest into the render-golden stage: deleting the --cookie-name emission gave
# rc 1 via an uncaught StopIteration and printed not one `SELFTEST FAIL:` line. Provoked
# again here with the sidecar removed entirely, which raised IndexError at this line.
side = fa_containers[1] if len(fa_containers) > 1 else {"args": [], "env": [], "ports": []}
check(
"--upstream=http://127.0.0.1:9117" in side["args"],
"sidecar upstream must be the co-located app UI over http on 127.0.0.1:<uiport>",
@@ -2809,12 +3232,101 @@ def _selftest() -> int:
"sidecar must declare BOTH the http and metrics container ports — an undeclared port "
"cannot be named by a Service port or a PodMonitor, so the flag alone is inert",
)
# API ROUTES (schema 1.9.0). Three properties, and the FIRST is the one that keeps the field
# honest: an app that declares nothing must render NO --api-route. `fa` above declares none.
check(
not any(a.startswith("--api-route") for a in side["args"]),
"a forward-auth app that declares no auth.apiRoutes must render NO --api-route. A "
"default or a renderer constant would answer PiHole's page loads with 401 — PiHole "
"serves no /api/ at all — so the service would look permanently broken to a "
"logged-out operator instead of signing them in",
)
# Same `next(..., None)` discipline as _cookie_name() below, for the same reason: if the
# sidecar stops being rendered at all, this must report as a named assertion rather than as a
# StopIteration that swallows every other SELFTEST FAIL line in the run.
_faapi_dep = next(
(
d[1]
for d in render(
{
"name": "faapi",
"source": {"image": "a:1"},
"ingressRoutes": [{"host": "faapi.home.keithjackson.dev", "port": 9119}],
"auth": {
"mode": "forward-auth",
"groups": ["media-users"],
"apiRoutes": ["^/api/", "^/signalr/"],
},
}
)
if d[1]["kind"] == "Deployment"
),
None,
)
fa_api_side = next(
(
c
for c in ((_faapi_dep or {}).get("spec", {}).get("template", {})
.get("spec", {}).get("containers") or [])
if c.get("name") == "oauth2-proxy"
),
None,
)
check(
fa_api_side is not None,
"a forward-auth app declaring auth.apiRoutes rendered NO oauth2-proxy sidecar, so the "
"routes have nowhere to land and the app is not gated at all (R-IDP-02)",
)
check(
fa_api_side is not None
and [a for a in fa_api_side["args"] if a.startswith("--api-route")]
== ["--api-route=^/api/", "--api-route=^/signalr/"],
"a declared auth.apiRoutes must render as one repeatable --api-route per entry, in "
"declared order, and NOTHING else. Note the flag is SINGULAR: --api-routes does not "
"exist in v7.8.1 and the proxy refuses to start with it",
)
# BREADTH. The renderer must REFUSE a regex that would match a page load, not merely
# discourage one, because the schema stage can be skipped and render-golden cannot.
for _bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"):
try:
render(
{
"name": "fabad",
"source": {"image": "a:1"},
"ingressRoutes": [{"host": "fabad.home.keithjackson.dev", "port": 9120}],
"auth": {
"mode": "forward-auth",
"groups": ["media-users"],
"apiRoutes": [_bad],
},
}
)
_refused = False
except SystemExit:
_refused = True
check(
_refused,
f"the renderer ACCEPTED auth.apiRoutes entry {_bad!r}. A too-broad --api-route "
f"answers a plain page load with 401, so a logged-out operator sees a bare error "
f"page and is never offered a sign-in — the service looks permanently broken "
f"rather than merely unauthenticated, which is a worse failure than the 302 bug "
f"this flag exists to fix",
)
for _good in ("^/api/", "^/signalr/", "^/api/v3/.*", "^/api/v2.0/server/update$"):
check(
bool(OAUTH2_API_ROUTE_PATTERN.match(_good)),
f"the breadth ratchet rejected the legitimate route {_good!r}. A ratchet that "
f"blocks real API prefixes is a ratchet someone eventually deletes",
)
# Two forward-auth apps must not be able to collide. Renders a SECOND one and compares, so a
# future refactor to a constant (or to a per-service table with a copy-paste slip) fails here
# rather than silently in a browser six weeks later.
fa2_side = next(
c
for c in next(
# Same None-tolerant shape as the `faapi` fixture above and for the same reason: a missing
# sidecar must surface as a named assertion, never as a traceback that suppresses the
# collected SELFTEST FAIL lines.
_fa2_dep = next(
(
d[1]
for d in render(
{
@@ -2825,24 +3337,55 @@ def _selftest() -> int:
}
)
if d[1]["kind"] == "Deployment"
)["spec"]["template"]["spec"]["containers"]
if c["name"] == "oauth2-proxy"
),
None,
)
fa2_side = next(
(
c
for c in ((_fa2_dep or {}).get("spec", {}).get("template", {})
.get("spec", {}).get("containers") or [])
if c.get("name") == "oauth2-proxy"
),
{"args": []},
)
# `next(..., None)` — the DEFAULT IS LOAD-BEARING, not defensive style. With a bare
# `next(genexpr)` this raises StopIteration the moment a sidecar renders no --cookie-name,
# which is precisely the mutation this assertion exists to catch. The traceback then escapes
# _selftest() before its collected `SELFTEST FAIL:` lines are printed, so the run exits
# non-zero with the RIGHT VERDICT AND NO DIAGNOSTICS — and it masks every other assertion the
# same mutation broke. Measured by Desmond Oyelaran while wiring the selftest into the
# render-golden stage: deleting the --cookie-name emission gave rc 1 via an uncaught
# StopIteration and suppressed all of the messages. A missing flag must report AS AN
# ASSERTION, with its name in the output, which is what the None + explicit check below do.
def _cookie_name(container):
return next(a for a in container["args"] if a.startswith("--cookie-name="))
return next((a for a in container["args"] if a.startswith("--cookie-name=")), None)
_fa_cn, _fa2_cn = _cookie_name(side), _cookie_name(fa2_side)
check(
_cookie_name(side) != _cookie_name(fa2_side),
_fa_cn is not None and _fa2_cn is not None,
f"a forward-auth sidecar rendered NO --cookie-name at all (fa={_fa_cn!r}, "
f"fa2={_fa2_cn!r}). Omitting the flag is not neutral: oauth2-proxy then claims its "
f"built-in default `_oauth2_proxy`, which every other instance on the platform also "
f"claims, so they overwrite and ClearSession-delete each other's sessions",
)
check(
_fa_cn != _fa2_cn,
"two forward-auth apps rendered IDENTICAL cookie names — they would overwrite each "
"other's session on every sign-in (the 2026-09-29 crafty defect, fleet-wide)",
)
# Index-safe for the same reason as `side` above: an absent sidecar has no env, and an
# IndexError here would exit non-zero with no diagnostics at all.
_fa_env = (side.get("env") or [{}])[0]
check(
side["env"][0]["valueFrom"]["secretKeyRef"]["name"] == "fa-oauth2-proxy",
(((_fa_env.get("valueFrom") or {}).get("secretKeyRef") or {}).get("name")
== "fa-oauth2-proxy"),
"sidecar creds must come by name from the per-app <app>-oauth2-proxy Secret (R-IDP-04)",
)
fa_svc = next(d[1] for d in fa_docs if d[1]["kind"] == "Service")
fa_svc_ports = {p["port"] for p in fa_svc["spec"]["ports"]}
fa_svc = next((d[1] for d in fa_docs if d[1]["kind"] == "Service"), None)
check(fa_svc is not None, "a forward-auth app must render a Service for the Gateway to reach")
fa_svc_ports = {p["port"] for p in (((fa_svc or {}).get("spec") or {}).get("ports") or [])}
check(
fa_svc_ports == {9117, 4180},
"forward-auth Service must expose the app port (east-west) AND :4180 (gateway->sidecar)",
+11
View File
@@ -100,6 +100,9 @@ dependencies:
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
port: 9117
protocol: tcp
- to: plex # arr -> plex: required since library is on NFS which does not provide file CRUD notifications
port: 32400
protocol: tcp
ingressRoutes:
- host: sonarr.${BASE_DOMAIN}
port: 8989 # Sonarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
@@ -112,6 +115,14 @@ auth:
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
- platform-admins # operators of these services must not be locked out of them
# NO `apiRoutes` HERE, AND NOT BECAUSE IT NEEDS NONE — because nothing measures that it does.
# Sonarr is nearly the same codebase as Radarr, which is exactly what makes `^/api/` +
# `^/signalr/` a tempting guess and still a guess. MEASURED 2026-10-01 on the live pod
# (sonarr-ddf485775-8ckwt, 0 restarts, 23h): its oauth2-proxy log contains 13,643 lines and
# ZERO unauthenticated requests — no /api/, no /signalr/, nothing but health probes. Nobody
# has opened this UI since the cookie fix rolled, so the measurement is empty rather than
# negative: it is evidence of no TRAFFIC, not evidence of no need. Add the routes when someone
# reproduces the 302-on-XHR here, with the numbers.
update:
channel: stable # R-UPD-01
range: ">=4.0.0 <5.0.0" # stay on the Sonarr v4 stable line
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""ci/approval-override.py — select the admin-override on a PR, if there is a valid one.
This script decides NOTHING about authority. It parses a PR's comments and reviews and returns
the single override *candidate* that ci/approval-policy.sh should then try to authorise against
the live Gitea permission API. Keeping the two apart is the point:
* parsing is pure, offline, and therefore TESTABLE (ci/tests/approval-override.test.sh);
* authorisation needs the API and must stay in the stage that fails closed.
A candidate is a comment OR a review whose body contains, AT THE START OF A LINE, the directive
followed by a non-empty reason:
/approve-override the gate cannot gate itself and I am the only human reviewer
TWO SOURCES, AND WHY THAT IS NOT BELT-AND-BRACES. Measured 2026-10-02 on PR #38: the override was
posted as an ordinary PR comment, was well-formed, was newer than the head commit — and the stage
still blocked, because CI could not READ it. `SETTINGS_API_TOKEN` is minted with scopes
`[read:repository, read:admin]`, and Gitea puts /issues/{n}/comments behind the *issue* scope, so
the request came back as an error OBJECT rather than an array. `/pulls/{n}/reviews` is under
read:repository and was readable in the same run. So:
* a REVIEW body works with the token CI has today (leave a review of type "Comment");
* a COMMENT body works once the token carries read:issue (see infra/gitea/set-ci-token.sh).
Accepting both means the hatch is not hostage to one API scope. A source that cannot be read
contributes no candidates and is reported on stderr; it can only cause a false BLOCK, never a
false pass, which is why degrading it is safe and blocking on it was not necessary.
WHY EACH RULE IS HERE
* START OF A LINE, not "anywhere in the body". Otherwise quoting someone else's override
("you could just /approve-override this") re-arms it. Lines beginning with '>' are DROPPED
rather than unquoted, for exactly that reason.
* A REASON IS MANDATORY. A bare directive records that a review was waived but not why, which
is the difference between an audit trail and a rubber stamp.
* STALE OVERRIDES ARE DISCARDED. An override earned at commit A must not silently cover commit
B. Any candidate created at or before the head commit's timestamp is dropped — the same
semantics Gitea's own `dismiss_stale_approvals` applies to approvals. Push new commits and
you must re-override.
* A PENDING REVIEW IS NOT SUBMITTED. It is a private draft visible only to its author, so it is
not an act of anything and is dropped.
* THE LATEST CANDIDATE WINS, across both sources, so a later override supersedes an earlier one.
Output: one TSV line (login, timestamp, source, reason) on stdout, or nothing at all when there is
no valid candidate. Never exits non-zero for "no override" — absence is a normal answer and the
caller must not be able to confuse it with a parse crash.
Input, all via env so nothing lands in `ps`:
COMMENTS_JSON the Gitea /issues/{n}/comments array (or an error object; or absent)
REVIEWS_JSON the Gitea /pulls/{n}/reviews array (or an error object; or absent)
HEAD_TIME ISO-8601 timestamp of the PR head commit
DIRECTIVE the override directive, e.g. /approve-override
"""
import json
import os
import re
import sys
from datetime import datetime, timezone
def parse_ts(value):
"""Gitea emits RFC3339. Return an aware datetime, or None if it is unparseable.
Unparseable is NOT 'treat as fresh': the caller drops such a candidate, because a
timestamp we cannot read is a staleness check we cannot perform.
"""
if not value:
return None
text = str(value).strip()
# Python < 3.11 cannot read a trailing 'Z' in fromisoformat.
if text.endswith(("Z", "z")):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def extract_reason(body, directive):
"""Return the reason from the first line that STARTS with the directive, else None."""
pattern = re.compile(
r"^[ \t]*" + re.escape(directive) + r"(?=[ \t:]|$)[ \t:]*(.*)$"
)
for raw in (body or "").splitlines():
# A quoted line is somebody repeating an override, not casting one.
if raw.lstrip().startswith(">"):
continue
match = pattern.match(raw)
if match:
reason = match.group(1).strip()
return reason or None
return None
def load_source(env_name, label):
"""Return a list of items for one source, or [] with a stderr note if it is unusable."""
raw = os.environ.get(env_name)
if not raw or not raw.strip():
return []
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
print(
"approval-override: %s is not valid JSON — ignoring that source" % label,
file=sys.stderr,
)
return []
if isinstance(parsed, list):
return parsed
# An error object, typically {"message": "token does not have ... scope"}. Say which,
# because "no override found" and "could not read the overrides" look identical from
# the outside and the first wasted a real debugging session.
detail = ""
if isinstance(parsed, dict):
detail = str(parsed.get("message") or parsed.get("errors") or "")[:200]
print(
"approval-override: %s is not a list (Gitea returned %s%s) — ignoring that source"
% (label, type(parsed).__name__, ": " + detail if detail else ""),
file=sys.stderr,
)
return []
def candidates_from(items, source, directive, head_time):
"""Yield (when, login, reason, raw_when, source) for every valid override in one source."""
found = []
for item in items:
if not isinstance(item, dict):
continue
login = ((item.get("user") or {}).get("login") or "").strip()
reason = extract_reason(item.get("body"), directive)
if not login or not reason:
continue
# A PENDING review is an unsubmitted private draft — not an act of anything.
state = str(item.get("state") or "").upper()
if source == "review" and state == "PENDING":
print(
"approval-override: dropping PENDING (unsubmitted) review by '%s'" % login,
file=sys.stderr,
)
continue
raw_when = (
item.get("submitted_at") or item.get("created_at") or item.get("updated_at") or ""
)
when = parse_ts(raw_when)
if when is None:
print(
"approval-override: dropping %s override by '%s' — unreadable timestamp %r"
% (source, login, raw_when),
file=sys.stderr,
)
continue
# An override must post-date the commit it covers.
if head_time is not None and when <= head_time:
print(
"approval-override: dropping STALE %s override by '%s' (%s) — not newer than "
"head commit (%s). Re-post it to override the current commit."
% (source, login, when.isoformat(), head_time.isoformat()),
file=sys.stderr,
)
continue
found.append((when, login, reason, raw_when, source))
return found
def main():
directive = os.environ.get("DIRECTIVE", "/approve-override").strip()
head_time = parse_ts(os.environ.get("HEAD_TIME", ""))
found = []
found += candidates_from(
load_source("COMMENTS_JSON", "COMMENTS_JSON"), "comment", directive, head_time
)
found += candidates_from(
load_source("REVIEWS_JSON", "REVIEWS_JSON"), "review", directive, head_time
)
if not found:
return 0
# Latest wins, across both sources: a later override supersedes an earlier one.
when, login, reason, raw_when, source = max(found, key=lambda c: c[0])
# Keep the reason on one line — this becomes a TSV field.
reason = " ".join(reason.split())
print("%s\t%s\t%s\t%s" % (login, raw_when, source, reason))
return 0
if __name__ == "__main__":
sys.exit(main())
+161 -4
View File
@@ -25,10 +25,45 @@
# true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a
# BYPASS, not a gap. So: cannot determine -> BLOCK.
#
# THE ADMIN OVERRIDE (added 2026-10-01), and the lock-out it clears.
#
# The rule above was UNSATISFIABLE for the one human who runs this repo. Gitea refuses to let a
# pull request's author approve their own PR, and `jakman85` is the only collaborator — so "an
# APPROVED review from someone who is not its author" is a condition the sole administrator can
# never meet. Every sensitive-path PR they opened was permanently unmergeable, INCLUDING the PR
# that would fix it. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, blocked.
#
# So an administrator may waive the review by COMMENTING on the PR:
#
# /approve-override <reason>
#
# Authority comes from the LIVE Gitea permission API, never from the comment — the commenter must
# hold a permission listed in `.override.authorized_permissions` (admin/owner) at the moment the
# gate runs, so an old comment from a since-demoted account is worth nothing. The directive must
# start a line, a reason is mandatory, and the comment must POST-DATE the head commit: an override
# earned at commit A does not cover commit B. Choosing the candidate comment is the job of the
# pure, offline, unit-tested ci/approval-override.py; AUTHORISING it is the job of this stage,
# which fails closed.
#
# WHAT THIS COSTS, STATED PLAINLY. For an administrator, "a change to the gate cannot be gated by
# the gate" stops being structural and becomes advisory. That is the unavoidable consequence of
# there being one human; the alternative is a repository its owner cannot change. What is bought
# back is a RECORD — the waiver is an explicit act, attributed to an account, carrying a stated
# reason, in the PR history and in this stage's output. A silent bypass keyed on "is the author an
# admin?" would have been less code and is exactly the fail-open shape this repo keeps finding.
#
# AND THE PART THAT IS NOT COMFORTABLE. Authorisation is by permission ALONE, with no allowlist of
# human accounts (operator decision, 2026-10-01, taken with this consequence on the table). The
# AGENT account `gitea-admin` also reports permission `owner` here, so an agent can clear the
# sensitive-path review on its own PR by posting the directive — and agents author most PRs in
# this repo, so that is the common case, not an edge case. To narrow it without touching this
# logic, add an `override_admins` list to infra/gitea/approval-policy.json: the check below
# already requires membership whenever that key is present.
#
# EXIT CODES
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a
# non-author APPROVED review is present
# 1 sensitive paths touched without such a review, OR the policy could not be evaluated
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and
# either a non-author APPROVED review or a valid admin override is present
# 1 sensitive paths touched with neither, OR the policy could not be evaluated
set -euo pipefail
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
@@ -65,6 +100,17 @@ api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)"
PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)"
REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)"
# PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review
# (diff) comments ONLY, so an override posted in the normal comment box would never appear in it.
# Verified against this Gitea (1.24.3) rather than assumed.
#
# This one is NOT in the must-parse loop below, and that is deliberate. /issues/{n}/comments sits
# behind Gitea's *issue* token scope, which `SETTINGS_API_TOKEN` does not carry (it is minted
# `[read:repository, read:admin]`) — so it returns an error OBJECT, not an array. Blocking on that
# turned "CI cannot read your override" into a message that said "you did not post one", and cost a
# real debugging session on PR #38. It is handled explicitly, and loudly, further down: an
# unreadable source can only cause a false BLOCK, never a false pass.
COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)"
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
@@ -107,11 +153,122 @@ if [ -n "$APPROVER" ]; then
exit 0
fi
# ─────────────────────────────────────────────────────────────────────────────────────────
# No non-author approval exists. Before refusing, look for an administrator's explicit
# override. See the header for why this hatch exists and what it costs.
# ─────────────────────────────────────────────────────────────────────────────────────────
OVERRIDE_ENABLED="$(jq -r '.override.enabled // false' "$DECL")"
DIRECTIVE="$(jq -r '.override.comment_directive // "/approve-override"' "$DECL")"
HEAD_SHA="$(jq -r '.head.sha // empty' <<<"$PR_JSON")"
if [ "$OVERRIDE_ENABLED" = "true" ]; then
# The head commit's timestamp anchors staleness. Read it from GITEA, not from the local
# tree: CI checks out a merge commit, whose date is not the head commit's date.
HEAD_TIME=""
if [ -n "$HEAD_SHA" ]; then
HEAD_COMMIT_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/git/commits/${HEAD_SHA}" || true)"
HEAD_TIME="$(jq -r '.commit.committer.date // .created // empty' <<<"${HEAD_COMMIT_JSON:-null}" 2>/dev/null || true)"
fi
if [ -z "$HEAD_TIME" ]; then
err "could not read the head commit's timestamp for PR #${PR} — BLOCKING."
err " Without it an override cannot be aged, and an unaged override is one that silently"
err " covers commits nobody waived review on."
summary "- approval-policy: **BLOCKED — head commit timestamp unreadable** (cannot age an override)"
exit 1
fi
# Is the comments source actually readable? Say so plainly if not — a scope problem and "you
# never posted an override" are indistinguishable from the outside, and conflating them is what
# made PR #38 look like a code bug.
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${COMMENTS_JSON:-}"; then
SCOPE_MSG="$(jq -r '.message // empty' <<<"${COMMENTS_JSON:-null}" 2>/dev/null || true)"
warn "cannot read PR COMMENTS from Gitea${SCOPE_MSG:+ — ${SCOPE_MSG}}."
warn " /issues/${PR}/comments needs the *issue* token scope; SETTINGS_API_TOKEN is minted"
warn " [read:repository, read:admin] (infra/gitea/set-ci-token.sh). An override posted as an"
warn " ordinary PR COMMENT is therefore invisible to CI — post it as a REVIEW of type"
warn " 'Comment' instead, which /pulls/${PR}/reviews exposes under read:repository."
summary "- approval-policy: note — PR comments unreadable (token lacks the issue scope); use a **review** to override"
COMMENTS_JSON='[]'
fi
# Pure, offline, unit-tested selection: which comment or review, if any, is a live override?
OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" REVIEWS_JSON="$REVIEWS_JSON" \
HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \
python3 "${CI_DIR}/approval-override.py")" || {
err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override."
summary "- approval-policy: **BLOCKED — override parser failed**"
exit 1; }
if [ -n "$OVERRIDE_TSV" ]; then
OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")"
OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")"
OV_SRC="$(cut -f3 <<<"$OVERRIDE_TSV")"
OV_WHY="$(cut -f4- <<<"$OVERRIDE_TSV")"
# AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git.
PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)"
OV_PERM="$(jq -r '.permission // empty' <<<"${PERM_JSON:-null}" 2>/dev/null || true)"
if [ -z "$OV_PERM" ]; then
err "'${OV_USER}' posted ${DIRECTIVE}, but their permission on ${GITEA_REPO} could not be"
err " read — BLOCKING. An override whose authority is unverified is not an override."
summary "- approval-policy: **BLOCKED — cannot verify \`${OV_USER}\`'s permission**"
exit 1
fi
# Authorised when the LIVE permission is one of the declared ones and — only if an
# `override_admins` allowlist exists at all — the account is on it. No allowlist means
# permission alone, which is the operator's standing decision; adding the key narrows
# this with no change to any code.
AUTHORISED="$(DECL="$DECL" OV_PERM="$OV_PERM" OV_USER="$OV_USER" python3 -c '
import json, os
d = json.load(open(os.environ["DECL"])).get("override", {})
perms = d.get("authorized_permissions") or ["admin", "owner"]
allow = d.get("override_admins")
ok_perm = os.environ["OV_PERM"] in perms
ok_list = True if allow is None else os.environ["OV_USER"] in allow
print("yes" if (ok_perm and ok_list) else "no")
')"
if [ "$AUTHORISED" = "yes" ]; then
if [ "$OV_USER" = "$AUTHOR" ]; then
warn "ADMIN OVERRIDE — SELF-WAIVED. '${OV_USER}' authored this PR and waived the"
warn " non-author review requirement on their own change. Permitted (permission"
warn " '${OV_PERM}'), deliberate, and shouted about here precisely because it is not free."
else
warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'."
fi
warn " via: PR ${OV_SRC}"
warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})"
warn " reason: ${OV_WHY}"
warn " review waived on: $(printf '%s ' $SENSITIVE)"
ok "sensitive paths cleared by administrator override — this stage does not block."
SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**"
summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`, via PR ${OV_SRC}${SELFNOTE})"
summary " - reason: ${OV_WHY}"
summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)"
exit 0
fi
err "'${OV_USER}' posted ${DIRECTIVE} but is NOT authorised to override: live permission is"
err " '${OV_PERM}'. The authorised set is declared in infra/gitea/approval-policy.json."
summary "- approval-policy: **BLOCKED — \`${OV_USER}\` may not override** (permission \`${OV_PERM}\`)"
exit 1
fi
fi
err "This PR changes the pipeline or the security posture it rests on, and has NO approving"
err " review from anyone other than its author ('${AUTHOR}')."
err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot"
err " honestly approve for itself. Get a human review, or move the change out of:"
printf ' %s\n' $SENSITIVE >&2
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**"
if [ "$OVERRIDE_ENABLED" = "true" ]; then
err " Or, if you hold admin/owner on this repository, post this on the PR:"
err " ${DIRECTIVE} <reason>"
err " as a REVIEW of type 'Comment' (not the plain comment box — CI's token cannot read"
err " /issues/${PR}/comments without the issue scope; see infra/gitea/set-ci-token.sh)."
err " The reason is mandatory, the directive must START a line, and it must be NEWER than"
err " head commit ${HEAD_SHA:0:8} — push again and you must override again."
fi
summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**"
exit 1
+54 -1
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env bash
# ci/render-golden.sh — Stage: render==golden reproducibility gate.
# ci/render-golden.sh — Stage: the renderer is trustworthy.
# (1) catalog/render.py passes its OWN output-contract self-test, and
# (2) every committed apps/ golden reproduces byte-for-byte from the canonical render.
# Two independent assertions about one subject, catalog/render.py. (1) was added
# 2026-10-01 because nothing in ci/ or .gitea/ had ever invoked `--selftest`; see the
# block above the invocation for why it lives here and not in a conformance check.
#
# WHY: the committed apps/ tree is a set of GOLDENS produced by ONE canonical
# invocation of catalog/render.py. If a golden is hand-edited, or a catalog
@@ -54,6 +59,54 @@ if [ ! -f "$RENDER_PY" ]; then
summary "- render-golden: **SKIPPED** (renderer missing)"
exit 3
fi
# --- The renderer's OWN self-test, before anything is compared ---------------
# `catalog/render.py --selftest` is ~106 executed assertions on the renderer's output
# contract (default-deny covers both directions, the Kyverno namespace hook label, a valid
# auth-mode, the oauth2-proxy sidecar's cookie-name/-domain/-refresh flags, the
# placeholder-substitution guard, the observability refusals...). Until now NOTHING in ci/
# or .gitea/ invoked it — measured by grep on 2026-09-30 and recorded as the top follow-up
# of decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md — so every one of those
# assertions was dev-time only and a merge gate could not tell a sound renderer from a
# gutted one.
#
# WHY HERE, AND NOT IN A CONFORMANCE CHECK. The lead was
# conformance/tests/l0-static/auditability-proofs-producible/check.sh, which drives
# `conformance/lib/{record_run,status}.py --selftest` "because nothing else drives them".
# That loop is NOT the right hook: that check serves R-SEC-03 and its subject is the
# EVIDENCE CHAIN (can a forged or stale run become a proof?). The renderer is not part of
# that chain, and hanging it there would make the check's name and its requirement mapping
# a lie. This stage's subject already IS catalog/render.py — it exists to assert that the
# renderer's output is trustworthy — so the renderer's own contract test belongs in it.
#
# It runs BEFORE the catalog/empty-catalog exit-3 below on purpose: the self-test is
# deterministic and catalog-independent (it renders synthetic ServiceDefinitions in
# memory), so an empty catalog must not be able to skip it.
#
# render-golden and the self-test are INDEPENDENT assertions, which is why both are needed:
# render-golden catches golden-vs-renderer drift but is satisfied by ANY renderer whose
# output was re-rendered, including one that just lost a contract invariant and had all ten
# goldens regenerated in the same commit. The self-test catches exactly that.
#
# FAILURE IS A HARD FAIL, NOT A SKIP: the renderer is source in this repo, so "could not
# run its self-test" has no legitimate environmental cause once python3 and render.py are
# both present (both asserted above). ci/gate.sh maps this stage's rc 1 to BLOCKED.
log "Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)"
selftest_out="$(mktemp)"
if "$PYTHON" "$RENDER_PY" --selftest >"$selftest_out" 2>&1; then
ok "render.py --selftest passed"
summary "- render-selftest: **passed** (\`catalog/render.py --selftest\`)"
else
err "catalog/render.py --selftest FAILED — the renderer violates its own output contract."
err " Every golden under apps/ is produced by this renderer, so the goldens below cannot"
err " be trusted even if they reproduce byte-for-byte. Reproduce with:"
err " ${PYTHON} catalog/render.py --selftest"
sed 's/^/ /' "$selftest_out" >&2 || true
summary "- render-selftest: **FAILED** (\`catalog/render.py --selftest\` — renderer contract violated)"
rm -f "$selftest_out"
exit 1
fi
rm -f "$selftest_out"
if [ ! -d "$CATALOG_DIR" ] || ! compgen -G "${CATALOG_DIR}/*.yaml" >/dev/null 2>&1; then
skip "no catalog ServiceDefinitions under '${CATALOG_DIR}' — nothing to render."
summary "- render-golden: **SKIPPED** (empty catalog)"
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env bash
# ci/tests/approval-override.test.sh — proves ci/approval-override.py selects the override it
# claims to select, and REJECTS the ones it claims to reject.
#
# This parser decides whether a human review requirement gets waived. Every rule in it exists
# because the obvious implementation ("does the body contain the directive?") is wrong in a way
# that is invisible until abused: a quoted directive would re-arm an override, a bare directive
# would waive review with no stated reason, and an override posted against commit A would go on
# covering commit B forever. A guard is worth nothing until somebody has watched it refuse, which
# is the same reasoning as ci/tests/decisions-log.test.sh one layer up.
#
# The parser is pure and offline BY DESIGN — authorisation (the live permission lookup) lives in
# ci/approval-policy.sh, which cannot be tested without Gitea. That split is what makes this file
# possible, and it is also why a green run here does NOT prove the stage authorises correctly.
set -uo pipefail
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SUT="${CI_DIR}/approval-override.py"
PASS=0 FAIL=0
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); }
HEAD_TIME="2026-10-01T12:00:00Z"
AFTER="2026-10-01T12:30:00Z"
BEFORE="2026-10-01T11:30:00Z"
# run <comments-json> -> stdout of the parser (stderr is diagnostic only)
run() {
COMMENTS_JSON="$1" REVIEWS_JSON="" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
python3 "$SUT" 2>/dev/null
}
# run_rev <reviews-json> — the same, but through the REVIEWS source.
run_rev() {
COMMENTS_JSON="" REVIEWS_JSON="$1" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
python3 "$SUT" 2>/dev/null
}
# comment <login> <created_at> <body>
comment() {
python3 -c '
import json, sys
print(json.dumps([{"user": {"login": sys.argv[1]}, "created_at": sys.argv[2], "body": sys.argv[3]}]))
' "$1" "$2" "$3"
}
expect_none() {
local label="$1" out
out="$(run "$2")"
if [ -z "$out" ]; then ok "$label"; else bad "$label (got: ${out})"; fi
}
expect_user() {
local label="$1" json="$2" want="$3" out got
out="$(run "$json")"
got="$(cut -f1 <<<"$out")"
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted ${want}, got '${got}')"; fi
}
expect_reason() {
local label="$1" json="$2" want="$3" out got
out="$(run "$json")"
got="$(cut -f4- <<<"$out")"
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted '${want}', got '${got}')"; fi
}
# check <label> <actual> <wanted>
check() {
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (wanted '${3}', got '${2}')"; fi
}
# review <login> <submitted_at> <state> <body>
review() {
python3 -c '
import json, sys
print(json.dumps([{"user": {"login": sys.argv[1]}, "submitted_at": sys.argv[2],
"state": sys.argv[3], "body": sys.argv[4]}]))
' "$1" "$2" "$3" "$4"
}
echo "== ci/approval-override.py"
# --- the happy path -------------------------------------------------------------------
expect_user "a directive with a reason, posted after the head commit, is selected" \
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" jakman85
expect_reason "the reason is returned verbatim" \
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" \
'sole admin, gate is green'
# --- absence is a normal answer, not an error -----------------------------------------
expect_none "no comments at all" '[]'
expect_none "an ordinary comment" "$(comment jakman85 "$AFTER" 'looks good to me')"
# --- a reason is MANDATORY ------------------------------------------------------------
expect_none "a bare directive (no reason) is refused" \
"$(comment jakman85 "$AFTER" '/approve-override')"
expect_none "a directive followed by only whitespace is refused" \
"$(comment jakman85 "$AFTER" '/approve-override ')"
# --- the directive must START a line --------------------------------------------------
expect_none "the directive mid-sentence does not count" \
"$(comment jakman85 "$AFTER" 'you could just /approve-override this one')"
expect_none "a QUOTED directive does not re-arm an override" \
"$(comment jakman85 "$AFTER" '> /approve-override quoting someone else')"
expect_user "leading whitespace before the directive is fine" \
"$(comment jakman85 "$AFTER" ' /approve-override indented but mine')" jakman85
expect_user "the directive on a later line of a longer comment counts" \
"$(comment jakman85 "$AFTER" 'some preamble
/approve-override the real reason here')" jakman85
# --- staleness: an override must post-date the commit it waives review on -------------
expect_none "an override OLDER than the head commit is dropped as stale" \
"$(comment jakman85 "$BEFORE" '/approve-override stale, predates the push')"
expect_none "an override exactly AT the head commit time is dropped (strictly newer)" \
"$(comment jakman85 "$HEAD_TIME" '/approve-override same instant')"
# --- malformed input is never a free pass --------------------------------------------
expect_none "an unreadable timestamp drops the candidate" \
"$(comment jakman85 'not-a-date' '/approve-override bad clock')"
expect_none "a comment with no author is dropped" \
'[{"user": {}, "created_at": "2026-10-01T12:30:00Z", "body": "/approve-override no login"}]'
expect_none "invalid JSON yields no override (and never a traceback into stdout)" \
'this is not json'
expect_none "a JSON object instead of a list yields no override" \
'{"user": "nope"}'
# --- latest wins ----------------------------------------------------------------------
TWO='[
{"user": {"login": "early"}, "created_at": "2026-10-01T12:10:00Z", "body": "/approve-override first"},
{"user": {"login": "latest"}, "created_at": "2026-10-01T12:40:00Z", "body": "/approve-override second"}
]'
expect_user "the LATEST override supersedes an earlier one" "$TWO" latest
expect_reason "and brings its own reason" "$TWO" second
# --- offsets, not just Z --------------------------------------------------------------
# Gitea emits RFC3339 WITH AN OFFSET (measured: 2026-10-01T23:09:08-05:00), not a trailing Z.
# A parser that only handles 'Z' silently drops every real override, so this case is load-bearing.
expect_user "an RFC3339 offset timestamp (-05:00), as Gitea actually emits, parses" \
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T08:30:00-05:00", "body": "/approve-override offset form"}]' \
jakman85
expect_none "and an offset timestamp that is genuinely earlier is still stale" \
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T06:00:00-05:00", "body": "/approve-override offset, stale"}]'
# --- the directive is configurable ----------------------------------------------------
out="$(run "$(comment jakman85 "$AFTER" '/admin-waive because policy says so')" "$HEAD_TIME" '/admin-waive')"
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
ok "a custom directive from the declaration is honoured"
else
bad "a custom directive from the declaration is honoured (got '${out}')"
fi
expect_none "the default directive does not match a custom-directive comment" \
"$(comment jakman85 "$AFTER" '/admin-waive because policy says so')"
# --- no staleness anchor: do not invent one -------------------------------------------
out="$(COMMENTS_JSON="$(comment jakman85 "$BEFORE" '/approve-override no anchor')" \
HEAD_TIME="" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
ok "with no HEAD_TIME the staleness test is skipped, not faked (caller blocks instead)"
else
bad "with no HEAD_TIME the candidate should still be returned (got '${out}')"
fi
# --- the REVIEWS source ---------------------------------------------------------------
# Load-bearing, not redundant. Measured on PR #38: SETTINGS_API_TOKEN is minted
# [read:repository, read:admin], /issues/{n}/comments needs the ISSUE scope, so a well-formed
# override in the comment box came back as an error object and was invisible. /pulls/{n}/reviews
# IS readable with that token, so a review body is the route that works today.
check "a review body carries an override" \
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
jakman85
check "the source is reported as 'review'" \
"$(cut -f3 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
review
check "the source is reported as 'comment' for a comment" \
"$(cut -f3 <<<"$(run "$(comment jakman85 "$AFTER" '/approve-override via a comment')")")" \
comment
out="$(run_rev "$(review jakman85 "$BEFORE" COMMENT '/approve-override stale review')")"
check "a stale review override is dropped too" "${out:-<none>}" "<none>"
out="$(run_rev "$(review jakman85 "$AFTER" PENDING '/approve-override unsubmitted draft')")"
check "a PENDING (unsubmitted) review is NOT an act of anything" "${out:-<none>}" "<none>"
check "an APPROVED review carrying the directive still counts as an override" \
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" APPROVED '/approve-override belt and braces')")")" \
jakman85
out="$(run_rev "$(review jakman85 "$AFTER" COMMENT 'ordinary review prose, no directive')")"
check "an ordinary review body is not an override" "${out:-<none>}" "<none>"
# --- an unreadable source degrades to 'no candidates', never to a pass ----------------
# The real-world shape: Gitea answers with {"message": "token does not have ... scope"}.
ERR_OBJ='{"message":"token does not have at least one of required scope(s): [read:issue]"}'
out="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
check "an error OBJECT from the comments API yields no override" "${out:-<none>}" "<none>"
out="$(COMMENTS_JSON="$ERR_OBJ" \
REVIEWS_JSON="$(review jakman85 "$AFTER" COMMENT '/approve-override reviews still work')" \
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
check "an unreadable COMMENTS source does not suppress a valid REVIEW override" \
"$(cut -f1 <<<"$out")" jakman85
# and it must SAY so, or a scope problem reads as 'you never posted one' (it did, on PR #38)
errout="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
DIRECTIVE=/approve-override python3 "$SUT" 2>&1 >/dev/null)"
case "$errout" in
*"not a list"*read:issue*) ok "the diagnostic names the scope Gitea complained about" ;;
*) bad "the diagnostic should quote Gitea's message (got: ${errout})" ;;
esac
# --- latest wins ACROSS the two sources ----------------------------------------------
out="$(COMMENTS_JSON="$(comment early "2026-10-01T12:10:00Z" '/approve-override from a comment')" \
REVIEWS_JSON="$(review later "2026-10-01T12:50:00Z" COMMENT '/approve-override from a review')" \
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
check "the latest override wins across both sources (review newer)" "$(cut -f1 <<<"$out")" later
check "and its source is labelled" "$(cut -f3 <<<"$out")" review
out="$(COMMENTS_JSON="$(comment later "2026-10-01T12:50:00Z" '/approve-override from a comment')" \
REVIEWS_JSON="$(review early "2026-10-01T12:10:00Z" COMMENT '/approve-override from a review')" \
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
check "the latest override wins across both sources (comment newer)" "$(cut -f1 <<<"$out")" later
check "and that source is labelled too" "$(cut -f3 <<<"$out")" comment
# --- both sources absent entirely -----------------------------------------------------
out="$(COMMENTS_JSON="" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
check "no sources at all yields no override, and no crash" "${out:-<none>}" "<none>"
printf '\n %d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] || exit 1
+10 -1
View File
@@ -42,7 +42,16 @@ cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env
# 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a
# PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the
# decision logic. `decisions-log` is added here in the same change that adds it to gate.sh.
for stage in spec-immutability decisions-log lint l0 render-golden l1 gitea-settings conformance; do
#
# AND IT HAPPENED AGAIN, with `approval-policy` (fixed 2026-10-01). Same shape, same cost: the
# stage was added to gate.sh by PR #17, never added here, so 15 of 50 assertions failed on `main`
# — measured on 0c7e8dd before touching anything, to be sure it was not the change under test.
# The note above was written about the previous instance and did not prevent the next one, which
# says the comment is not the fix. THE FIX IS TO DERIVE THE LIST: it now comes from gate.sh's own
# `run_stage` calls, so a stage added there can no longer be silently missing here.
mapfile -t STAGES < <(sed -n 's/^run_stage[[:space:]]\+\([a-z0-9-]\+\).*/\1/p' "${REAL_CI_DIR}/gate.sh")
[ "${#STAGES[@]}" -ge 8 ] || { echo "could not parse stage names out of gate.sh (got ${#STAGES[@]})" >&2; exit 1; }
for stage in "${STAGES[@]}"; do
cat > "${CI}/${stage}.sh" <<'STUB'
#!/usr/bin/env bash
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
+22 -1
View File
@@ -100,7 +100,8 @@ data:
# platform/keycloak/postgres-cluster.yaml but is rendered by platform-config, which is the ONLY
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH; pihole + pihole-dns: PIHOLE_DNS_VIP, LAN_CIDR, plus
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers),
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers;
# plex-remote + pihole: PLEX_L4_VIP),
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
# UNSUBSTITUTED path would ship the literal string into the cluster.
@@ -175,6 +176,26 @@ data:
# dev -> 192.168.1.56 (base default; next free after crafty .55 in dev's .53-.60 pool)
# prod -> 192.168.2.23 (next free after crafty .22 in prod's .20-.30 pool; see clusters/prod)
PIHOLE_DNS_VIP: "192.168.1.56" # PiHole: authoritative LAN DNS for ${BASE_DOMAIN}
# Plex's remote-access listener: raw tcp/32400 for REMOTE Plex clients (apps/wave-0/plex-remote.yaml).
# A FOURTH pinned L4 VIP, for the usual reason — the `shared` Gateway is an L7 HTTPS terminator and
# cannot carry Plex's own TLS. Plex clients do not resolve `plex.${BASE_DOMAIN}`; they fetch this
# server's address from plex.tv, so the Gateway's L7 path (which still serves the web UI, and was
# measured working) cannot put the server on a client's connection list at all.
#
# PINNED, AND THE PIN IS LOAD-BEARING OFF-REPO: the SOHO router forwards WAN tcp/32400 to this
# address by hand. A VIP taken from the pool head would silently move on a reschedule and break
# remote access with nothing in-cluster looking wrong. See runbooks/plex-remote-access.md.
#
# dev -> 192.168.1.57 (base default; next free after pihole-dns .56 in dev's .53-.60 pool)
# prod -> 192.168.2.24 (next free after pihole-dns .23 in prod's .20-.30 pool; see clusters/prod)
PLEX_L4_VIP: "192.168.1.57" # Plex: tcp/32400 remote access (NOT the web UI, which is L7)
# SECOND CONSUMER since 2026-10-04: catalog/pihole.yaml reads this knob to answer
# plex-vip.${BASE_DOMAIN} on the LAN, which is the local half of the single hostname Plex
# advertises to plex.tv. Without that per-host answer the zone wildcard sends the name to
# ${GATEWAY_VIP}, where nothing listens on 32400. So this value is now typed into THREE places
# that must agree: this knob, the WAN port-forward on the SOHO router (off-repo), and PiHole's
# answer -- and only the first two were true before.
# The FLAT LAN this cluster's nodes and LB VIPs live on, in CIDR form. Consumed as the ingress
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
+20
View File
@@ -476,6 +476,26 @@ patches:
- op: replace
path: /data/PIHOLE_DNS_VIP
value: "192.168.2.23"
# Plex's remote-access listener VIP — raw tcp/32400 for REMOTE Plex clients
# (apps/wave-0/plex-remote.yaml). 192.168.2.24 is the NEXT FREE address in the prod pool
# 192.168.2.20-.30, verified live 2026-10-03: the CiliumLoadBalancerIPPool `default-pool`
# reported IPsTotal 11 / IPsUsed 4 / IPsAvailable 7, and the only four allocations are .20
# (cilium-gateway-shared), .21 (unifi-l4-tcp/-udp), .22 (crafty-l4-tcp/-udp) and .23
# (pihole-dns-tcp/-udp). It is inside the pool block the operator already asserted sits outside
# the SOHO router's DHCP scope, and it is not a node address (.10/.11/.12) or the
# control-plane VIP (.9).
#
# UNLIKE .23, THIS ONE IS ALSO A WAN PORT-FORWARD TARGET. The router forwards WAN tcp/32400
# here, by hand, off-repo — so changing this value is a two-place edit and the router is the
# half that CI cannot check. runbooks/plex-remote-access.md is the procedure.
#
# NOT ARP-PROBED. .23 was verified free with `arping` from a LAN host before it was claimed;
# for .24 only the in-cluster pool accounting above was checked, so an off-cluster squatter on
# this address would not have been caught. Probe before trusting it if remote access comes up
# with a conflict.
- op: replace
path: /data/PLEX_L4_VIP
value: "192.168.2.24"
# The prod flat LAN. This is the 192.168.2.0/24 segment the SOHO ROUTER at 192.168.2.1 owns —
# NOT pfSense's 192.168.1.0/24, which is a different network and not in the LAN-DNS path.
# It scopes the ingress allow for PiHole's DNS listener to LAN clients only.
@@ -0,0 +1,134 @@
# =============================================================================
# L0 STATIC — `--api-route` is PER APP, ANCHORED, and DECLARED
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
#
# THE DEFECT THIS CLOSES. oauth2-proxy answers an unauthenticated request with a 302 to
# Keycloak. Its built-in AJAX guard would send a 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 Keycloak
# SSO session, and resolves 200 carrying the app's HTML where the JavaScript expected JSON — no
# prompt, no console error, nothing logged as a failure. 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, twice, on two different apps and two different paths:
# jackett 2026-09-30 — on /api/ paths: 33 x 302 against 2 x 401, and 4 of 6 POSTs answered BY
# THE PROXY, never reaching Jackett (access-log upstream field `-`, not `/`). That is
# the operator's whole report: "GET works, most PUT/POST to add indexers fail,
# reloading never fixes it, and I am never re-prompted for login".
# radarr 2026-10-01, 23h after the cookie fix rolled (pod radarr-7d55cbffb9-42hmh, 0
# restarts) — 232 unauthenticated requests in the pod's whole lifetime and ALL 232 are
# the same call: `POST /signalr/messages/negotiate` from a browser UA, 302, upstream
# `-`. A left-open tab whose session the cookie rename orphaned, polling SignalR
# forever, silently redirected every time, NEVER re-prompting. The reported symptom,
# still running in production, on a service nobody was watching.
#
# WHY IT CANNOT BE A RENDERER CONSTANT — AND WHY THAT NEEDS A GATE. The path shape belongs to
# the APP, not to the forward-auth pattern: radarr's broken path is `/signalr/`, jackett's is
# `/api/`, and PIHOLE SERVES NO `/api/` AT ALL. One function renders all five sidecars
# (catalog/render.py::_oauth2_sidecar_container), so the cheap fix is a constant — and a
# constant applies one app's measurement to four apps nobody measured. The gate that already
# exists cannot see that mistake: `ci/render-golden.sh` compares the render to the goldens, and
# a constant plus regenerated goldens AGREE WITH EACH OTHER while both being wrong. Hence
# assertion (2) below, which compares the rendered flag against each service's OWN
# ServiceDefinition rather than against the renderer's output.
#
# WHY BREADTH IS THE DANGEROUS DIRECTION, AND IT IS NOT SYMMETRIC. A MISSING --api-route is a
# silent 302 — bad, invisible, recoverable. A TOO BROAD one answers a plain PAGE LOAD with 401:
# a logged-out operator gets a bare error page and is NEVER OFFERED A SIGN-IN, so the service
# looks permanently broken and no reload recovers it. That is a worse outcome than the defect
# the flag fixes, which is why the anchoring rule is asserted on three independent surfaces
# (schema items.pattern, the renderer, and this check) and why assertion (5) fails if they drift
# apart — if they do, the loosest one wins silently.
#
# GIVEN every oauth2-proxy sidecar this repo DEPLOYS (a container in any manifest at any depth:
# the rendered sidecars in apps/, crafty's hand-authored one in platform/), the
# ServiceDefinitions in catalog/ that generate five of them, and the renderer itself,
# WHEN each sidecar's `--api-route` values are collected (the flag is REPEATABLE, so all of
# them, in order) and each onboarded forward-auth service is paired with its golden,
# THEN (1) every rendered --api-route is anchored at `^/` and continues with literal path
# characters — `^/`, `^/.*`, `.*`, an unanchored `/api/` and any alternation FAIL;
# (2) every onboarded forward-auth service's rendered route list EQUALS its own
# ServiceDefinition's `auth.apiRoutes`, in order — which fails both a hand-edited
# golden and a renderer constant, neither of which render-golden can see;
# (3) BOTH cases are live in this tree: at least one service declares routes and at least
# one declares none and renders none (the pihole case), so neither path is an
# unexercised seam;
# (4) `_oauth2_sidecar_container()` reads `auth.apiRoutes` and contains NO literal path
# regex — a constant is the defect, back in the one place that reintroduces it
# fleet-wide in a single edit;
# (5) the anchoring pattern in the renderer and in the schema are the SAME pattern this
# check enforces;
# (6) the SHIPPED renderer, run as a subprocess on synthetic ServiceDefinitions, emits
# exactly the declared routes in order, emits NOTHING for a service declaring none or
# an empty list, and REFUSES all five breadth shapes.
#
# WHY ALSO A SCHEMA PATTERN, GIVEN THIS CHECK. The schema is the only surface that rejects a
# too-broad regex WHERE A HUMAN TYPES IT — `ci/l0.sh` validates `catalog/*.yaml` against it on
# every gate run, so the operator is told at the authoring surface rather than finding out from
# a 401 on a page load. The renderer's own guard is there because `render-golden` runs even on a
# gate invocation where the schema stage skipped. This check is there because neither of those
# can see a renderer constant. Three surfaces, three different blind spots.
#
# WHY THIS IS A SIBLING AND NOT PART OF oauth2-proxy-cookie-names-distinct. That check's subject
# is cookie IDENTITY: its inputs are names and domains, its failure mode is mutual session
# eviction, its register is the shared proxy's claim on the default name. `--api-route` shares
# none of those. Bundling them would make that check's name a lie and couple two unrelated
# ratchets, so a future api-route decision could only be expressed by weakening a COOKIE check —
# the same discipline that check's own header argues for, applied to this change.
#
# WHAT THIS DOES NOT ESTABLISH — read with the handoff entry, not instead of it:
# - That a 401 FIXES anything in a browser. No browser round-trip has been made, by anyone,
# for this flag. The 302/401 split, the dropped POST bodies and the orphaned SignalR poll
# are all read from access logs; the repair is reasoned from HTTP semantics.
# - That the regexes are RIGHT for these two apps beyond the measured paths. `^/api/` on
# radarr is justified by a measurement of radarr taken on its PRE-FIX pod by another
# engineer (decisions/2026-09-30-jackett-oauth2-proxy-session.md: 62 x /api/v3/command,
# 46 x /api/v3/queue/details); the current pod has logged zero /api/ traffic.
# - Anything about sonarr, qbittorrent or pihole. Their logs were read (2026-10-01) and
# contain ZERO unauthenticated requests — no traffic, which is not the same as no need.
# Assertion (3) therefore proves the "declares none" PATH works; it does not claim those
# three apps are correctly configured.
# - That the LIVE pods carry what the tree says. git is not the cluster (CLAUDE.md §2b).
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: oauth2-proxy-api-routes-per-app
labels:
conformance.home.k3s/R-IDP-02: "true"
annotations:
conformance.home.k3s/requirements: "R-IDP-02"
conformance.home.k3s/layer: "L0"
# WHOLE-REPO SCOPE — the subject spans apps/wave-0, apps/wave-1, platform/crafty, every
# ServiceDefinition in catalog/, the renderer that generates five of the sidecars, and the
# ServiceDefinition schema. No single requirement path-maps to that set, and the renderer
# maps to no identity requirement at all, so a requirement-scoped selection would skip this
# check on precisely the change that can reintroduce the defect fleet-wide in one edit.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
An unauthenticated XHR is answered 401, not 302, on exactly the paths each app was
MEASURED to serve as a machine API: every rendered --api-route is anchored at ^/ and
continues with literal path characters, every onboarded forward-auth service's rendered
routes equal its own auth.apiRoutes in order (so neither a hand-edited golden nor a
renderer constant can pass), an app declaring none renders none, the anchoring pattern is
the same in the schema and the renderer, and the shipped renderer is proven on synthetic
ServiceDefinitions in both directions — including refusing the five breadth shapes that
would answer a page load with 401.
spec:
steps:
- name: assert-api-route-is-per-app-anchored-and-declared
description: >-
Inventory every oauth2-proxy sidecar's repeatable --api-route values, assert each is an
anchored path regex, cross-check each onboarded forward-auth service's rendered routes
against its own ServiceDefinition, require both the declares-routes and the
declares-none cases to be live in the tree, assert the renderer derives from
auth.apiRoutes and hardcodes no path regex, assert the schema and renderer share this
check's anchoring pattern, and prove the shipped renderer on synthetic
ServiceDefinitions in both directions.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,433 @@
#!/usr/bin/env bash
# =============================================================================
# L0 STATIC CHECK — `--api-route` is PER APP, ANCHORED, and DECLARED.
# See chainsaw-test.yaml's header for WHY this exists and what it measured. In one paragraph:
#
# oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. A browser XHR follows
# that redirect invisibly, the live SSO session satisfies it silently, and the call resolves 200
# carrying the app's HTML where its JavaScript expected JSON — and a POST following a 302 is
# re-issued as a GET with its BODY DROPPED. `--api-route=<regex>` makes matching paths answer
# 401 instead. The flag is right for jackett (`/api/`) and radarr (`/signalr/`, `/api/`) and
# WRONG for pihole, which serves no `/api/` at all — so it cannot be a renderer constant, and
# the point of this check is that it never becomes one again.
#
# The breadth direction is the dangerous one and it is NOT symmetric with the bug. 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. That is a worse outcome than the defect, which is why the anchoring rule
# is asserted here as well as in the schema.
#
# Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns
# into rc 3 = UNWIRED, never green, because this test declares the whole-repo scope) only if the
# tree contains no forward-auth oauth2-proxy sidecar at all — genuinely nothing to audit.
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
PYTHON="${PYTHON:-python3}"
# Honest SKIP (never green for a scope-declaring test) if the forward-auth pattern has left the
# repo. Anchored on `image:` for the same reason as the sibling cookie check: a bare
# `oauth2-proxy/oauth2-proxy:` also matches platform/oauth2-proxy/forward-auth-cec.yaml's Envoy
# cluster reference (`serviceName: oauth2-proxy/oauth2-proxy:80`), which runs no proxy.
if ! grep -rlE 'image:[[:space:]]*[^[:space:]]*oauth2-proxy' \
"${ROOT}/apps" "${ROOT}/platform" >/dev/null 2>&1; then
echo "check SKIPPED: no oauth2-proxy container image found under apps/ or platform/ — the forward-auth sidecar pattern is gone and there is nothing to audit" >&2
exit 3
fi
cd "${ROOT}"
"${PYTHON}" - "${ROOT}" <<'PY'
"""Static audit: --api-route is per-app, anchored, and declared in the ServiceDefinition.
PyYAML (the same dependency as catalog/render.py and the sibling l0-static checks) rather than
line-based parsing: a container's args live in a nested structure, and a line-based reader of
one is precisely the approximate parser that silently stops matching.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
file=sys.stderr)
raise SystemExit(1)
ROOT = Path(sys.argv[1])
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
FAILURES: list[str] = []
CHECKS = 0
def check(cond, msg):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(msg)
# THE BREADTH RATCHET, stated once and asserted against three independent surfaces below (the
# rendered tree, the authored catalog, the shipped renderer). Each entry must be anchored at
# `^/` and continue with LITERAL path characters. This rejects:
# `^/` and `^/.*` — match every page load, so the UI answers 401 and never signs in
# `/api/` — unanchored; oauth2-proxy would match it mid-path
# `.*` — everything
# `^/api/|^/` — alternation smuggles breadth past a prefix-only reading
# Alternation is REFUSED rather than supported because `--api-route` is repeatable: one entry
# per route costs nothing and each entry stays readable. Kept byte-identical to
# catalog/render.py's OAUTH2_API_ROUTE_PATTERN and to the schema's items.pattern; assertion (5)
# below fails if they drift apart.
ANCHORED = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$")
SCHEMA_PATTERN = r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$"
def read(p: Path) -> str:
try:
return p.read_text()
except (OSError, UnicodeDecodeError):
return ""
def is_test_fixture(p: Path) -> bool:
"""conformance/tests/**/{golden,fixtures}/** is assertion DATA, not deployed manifests."""
parts = p.parts
return "conformance" in parts and ("golden" in parts or "fixtures" in parts)
def iter_containers(node):
"""Yield every container mapping under any `containers`/`initContainers` list, at any depth.
Depth-agnostic on purpose: the same container shape appears under a Deployment, a DaemonSet,
a CronJob's jobTemplate or a bare Pod, and a reader that hardcoded one path would silently
stop seeing an instance the day one moved.
"""
if isinstance(node, dict):
for key, val in node.items():
if key in ("containers", "initContainers") and isinstance(val, list):
for c in val:
if isinstance(c, dict):
yield c
else:
yield from iter_containers(val)
elif isinstance(node, list):
for item in node:
yield from iter_containers(item)
def api_routes_of(args: list) -> list:
"""Every `--api-route=<v>` / `--api-route <v>` value, in order. The flag is REPEATABLE, so
this must collect ALL of them — a single-value reader would audit the first and wave the
rest through, which is the shape of reader bug this file's header warns about."""
out = []
for i, a in enumerate(args):
if not isinstance(a, str):
continue
if a.startswith("--api-route="):
out.append(a.split("=", 1)[1])
elif a == "--api-route" and i + 1 < len(args) and isinstance(args[i + 1], str):
out.append(args[i + 1])
return out
# ============================================================ (0) INVENTORY: THE RENDERED TREE
# Every oauth2-proxy container this repo DEPLOYS, keyed by file + owner, with its api-routes.
rendered: dict[str, list] = {}
for p in sorted(ROOT.rglob("*.yaml")):
relparts = p.relative_to(ROOT).parent.parts
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs,
# so a bare rglob audits up to 18 duplicates of every file — which both invents FAILs and,
# worse, can satisfy a "something declares X" check from a copy rather than from real source.
if any(part.startswith(".") for part in relparts) or is_test_fixture(p) or ".sops." in p.name:
continue
txt = read(p)
if "oauth2-proxy" not in txt:
continue
rel = str(p.relative_to(ROOT))
try:
docs = [d for d in yaml.safe_load_all(txt) if isinstance(d, dict)]
except yaml.YAMLError as exc:
check(False, f"{rel} is not parseable YAML ({exc}) — any oauth2-proxy instance in it "
f"went unaudited, so a too-broad --api-route could be hiding there")
continue
for d in docs:
for c in iter_containers(d):
image = str(c.get("image") or "")
name = str(c.get("name") or "")
if "oauth2-proxy" not in image and name != "oauth2-proxy":
continue
owner = (d.get("metadata") or {}).get("name") or "?"
args = list(c.get("args") or []) + list(c.get("command") or [])
rendered[f"{rel}:{owner}/{name or 'oauth2-proxy'}"] = api_routes_of(args)
check(len(rendered) >= 5,
f"only {len(rendered)} oauth2-proxy container(s) parsed out of the tree. This repo "
f"deploys more than that (five rendered sidecars plus crafty's hand-authored one), so the "
f"inventory has stopped seeing instances and every assertion below would pass vacuously "
f"while an unanchored --api-route went unaudited")
# ============================================================ (1) EVERY ROUTE IS ANCHORED
for key, routes in sorted(rendered.items()):
for r in routes:
check(bool(ANCHORED.match(r)),
f"{key} declares --api-route={r!r}, which is not an anchored path regex.\n"
f" WHY THIS IS WORSE THAN THE BUG IT FIXES: --api-route makes matching paths "
f"answer 401 instead of 302ing to Keycloak. A regex that also matches a path a "
f"BROWSER NAVIGATES to — `^/`, `^/.*`, `.*`, or an alternation that reaches the "
f"UI root — answers a plain PAGE LOAD with 401. A logged-out operator then sees a "
f"bare error page and is NEVER OFFERED A SIGN-IN: the service looks permanently "
f"broken rather than merely unauthenticated, and no reload can recover it.\n"
f" FIX: anchor it at '^/' followed by literal path characters "
f"(e.g. '^/api/', '^/signalr/', '^/api/v3/.*'), one entry per route — the flag is "
f"repeatable, so alternation is never needed. For a service rendered from catalog/, "
f"do NOT hand-edit the golden: declare auth.apiRoutes and re-render "
f"(ci/render-golden.sh's header has the canonical command)")
# ============================================================ (2) DERIVED FROM THE CATALOG
# The five rendered sidecars are OUTPUTS. Auditing only them would let someone "fix" a golden by
# hand (reverted by the next render) or make the renderer emit a CONSTANT and regenerate every
# golden to match — which render-golden cannot see, because render and golden would agree.
# So: every onboarded forward-auth service's rendered route list must EQUAL its own
# ServiceDefinition's auth.apiRoutes, in order.
catalog_dir = ROOT / "catalog"
declared: dict[str, list] = {}
forward_auth: list[str] = []
for sd_path in sorted(catalog_dir.glob("*.yaml")):
try:
sd = yaml.safe_load(read(sd_path))
except yaml.YAMLError as exc:
check(False, f"catalog/{sd_path.name} is not parseable YAML ({exc})")
continue
if not isinstance(sd, dict) or not sd.get("name"):
continue
auth = sd.get("auth") or {}
routes = auth.get("apiRoutes") or []
declared[sd["name"]] = list(routes)
if auth.get("mode") == "forward-auth" and (sd.get("ingressRoutes") or []):
forward_auth.append(sd["name"])
for r in routes:
check(bool(ANCHORED.match(str(r))),
f"catalog/{sd_path.name}: auth.apiRoutes entry {r!r} is not anchored at '^/' "
f"followed by literal path characters. See assertion (1) for why breadth here is "
f"the dangerous direction. The schema rejects this too (items.pattern); this "
f"assertion exists because the schema stage can be SKIPPED and this one cannot")
if routes:
check(auth.get("mode") == "forward-auth",
f"catalog/{sd_path.name} declares auth.apiRoutes with auth.mode="
f"{auth.get('mode')!r}. --api-route renders onto the per-app oauth2-proxy sidecar, "
f"and no sidecar is rendered for oidc/native/none — so this is a SILENT NO-OP: the "
f"operator believes the app's XHRs now answer 401 and nothing changed")
matched = 0
for name in sorted(forward_auth):
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
if golden is None:
continue # catalog service not onboarded to apps/ yet — no golden
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
check(key is not None,
f"apps/{golden.name} is the golden for forward-auth service {name!r} but no "
f"oauth2-proxy container was parsed out of it. Its --api-route configuration is "
f"UNAUDITED")
if key is None:
continue
matched += 1
check(rendered[key] == declared.get(name, []),
f"{name}: the rendered sidecar declares --api-route {rendered[key]!r} but "
f"catalog/{name}.yaml declares auth.apiRoutes {declared.get(name, [])!r}.\n"
f" These must be IDENTICAL and in order. A mismatch means one of two things, "
f"both bad: a golden was hand-edited (the next re-render silently reverts it), or the "
f"renderer is emitting --api-route from somewhere other than this service's own "
f"ServiceDefinition — i.e. a constant or a per-service table. A constant is the defect "
f"this field exists to prevent: it applies ONE app's path shape to all five, and "
f"pihole serves no /api/ at all, so it would answer pihole's page loads with 401. "
f"render-golden cannot catch that, because render and golden would agree with each "
f"other while both being wrong")
check(matched >= 4,
f"only {matched} onboarded forward-auth service(s) were cross-checked against their "
f"ServiceDefinition. This repo onboards five, so the catalog<->golden pairing has stopped "
f"resolving and assertion (2) is passing vacuously")
# ============================================================ (3) BOTH CASES EXIST
# Once the tree is consistent, (2) is satisfied by any pairing — including one where NOTHING
# declares a route (vacuous) or one where EVERYTHING does (so the "renders nothing" path is
# never exercised). Both halves are asserted to be live, because the per-app field only earns
# its complexity if both outcomes actually occur in this tree.
with_routes = [n for n in forward_auth if declared.get(n)]
without_routes = [n for n in forward_auth if not declared.get(n)]
check(bool(with_routes),
"no onboarded forward-auth service declares auth.apiRoutes, so assertion (2) cannot "
"distinguish a working renderer from one that emits the flag nowhere. If the flag is "
"genuinely not needed anywhere, delete the field rather than leaving an unexercised seam")
check(bool(without_routes),
"EVERY onboarded forward-auth service declares auth.apiRoutes, so nothing in this tree "
"exercises the 'declares none => renders no flag' path. That path is the whole reason the "
"field is per-app: pihole serves no /api/, and a renderer-wide default would answer its "
"page loads with 401")
for name in sorted(without_routes):
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
if golden is None:
continue
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
if key is None:
continue
check(rendered[key] == [],
f"{name} declares NO auth.apiRoutes but its rendered sidecar carries "
f"--api-route {rendered[key]!r}. An app that declares nothing must render NOTHING")
# ============================================================ (4) THE RENDERER DERIVES
render_py = ROOT / "catalog" / "render.py"
check(render_py.is_file(),
"catalog/render.py is missing — the generator of five of these sidecars cannot be audited")
if render_py.is_file():
src = read(render_py)
m = re.search(r"def _oauth2_sidecar_container\(.*?\n(?=\n*def |\n*# =)", src, re.S)
fn = m.group(0) if m else ""
check(bool(fn),
"could not locate _oauth2_sidecar_container() in catalog/render.py. It was renamed or "
"removed, so assertion (4) cannot run and the generator is unaudited")
if fn:
check("--api-route=" in fn,
"catalog/render.py's _oauth2_sidecar_container() emits no --api-route at all, so "
"every forward-auth app's XHRs are answered with a 302 the browser follows "
"invisibly — measured as 33x302-vs-2x401 on jackett and 232x302 on radarr, with "
"POST bodies dropped. auth.apiRoutes would then be a field with no consumer")
check(not re.search(r'--api-route=\^/[A-Za-z0-9]', fn),
"catalog/render.py hardcodes a literal path regex into --api-route. Whatever path "
"that is, EVERY forward-auth app now renders it: one app's path shape applied to "
"five. pihole serves no /api/, so a constant answers its page loads with 401 and "
"the service looks permanently broken to a logged-out operator. Render the routes "
"declared in each service's own auth.apiRoutes instead")
check("apiRoutes" in fn,
"catalog/render.py's _oauth2_sidecar_container() does not read auth.apiRoutes, so "
"the per-app declaration has no effect on what is rendered")
# ============================================================ (5) ONE PATTERN, THREE SURFACES
# The ratchet is written in three places (this check, the renderer, the schema) because each
# guards a surface the others cannot. If they drift apart, the loosest one wins silently.
if render_py.is_file():
# The renderer writes the pattern as a raw string literal, so the source text contains it
# verbatim. A substring test is deliberate: it also catches a rename of the constant.
check(SCHEMA_PATTERN in read(render_py),
f"catalog/render.py no longer contains the anchoring pattern {SCHEMA_PATTERN!r} that "
f"this check and the schema enforce. Three surfaces guard --api-route breadth (the "
f"authoring surface via the schema, the render via the renderer, the tree via this "
f"check); if they drift apart the loosest wins and the ratchet is decorative")
# The schema's pattern is read from the PARSED JSON, not from the file text: in the file its
# backslashes are JSON-escaped, so a substring test against the raw bytes would compare the
# wrong string and pass or fail for the wrong reason.
schema_file = ROOT / "schemas" / "servicedefinition.schema.json"
schema_pat = None
if schema_file.is_file():
try:
schema_pat = (((((json.loads(read(schema_file)).get("properties") or {})
.get("auth") or {}).get("properties") or {})
.get("apiRoutes") or {}).get("items") or {}).get("pattern")
except (ValueError, AttributeError) as exc:
check(False, f"schemas/servicedefinition.schema.json could not be read as JSON ({exc}) — "
f"the schema stage that rejects a too-broad --api-route cannot be audited")
check(schema_pat == SCHEMA_PATTERN,
f"schemas/servicedefinition.schema.json constrains auth.apiRoutes items with "
f"{schema_pat!r}, not {SCHEMA_PATTERN!r}. The schema is the only surface that rejects a "
f"too-broad regex WHERE A HUMAN TYPES IT (ci/l0.sh validates catalog/*.yaml on every gate "
f"run); if it is absent or looser than the renderer's, the operator finds out from a 401 "
f"on a page load")
# ============================================================ (6) PROVE THE SHIPPED RENDERER
# Once the tree is compliant, (1)-(3) have nothing left to catch and their passing is evidence
# of nothing. This runs the SHIPPED renderer as a SUBPROCESS on synthetic ServiceDefinitions and
# asserts the behaviour directly, in BOTH directions, so the guard stays falsifiable.
def _sd(name: str, port: int, routes) -> str:
auth = {"mode": "forward-auth", "groups": ["platform-admins"]}
if routes is not None:
auth["apiRoutes"] = routes
return yaml.safe_dump({
"name": name,
"kind": "container",
"source": {"image": "example/app:1"},
"exposure": "internal",
"ingressRoutes": [{"host": f"{name}.home.keithjackson.dev", "port": port}],
"auth": auth,
}, sort_keys=False)
def _render(name: str, port: int, routes):
with tempfile.TemporaryDirectory() as td:
f = Path(td) / f"{name}.yaml"
f.write_text(_sd(name, port, routes))
return subprocess.run([sys.executable, str(render_py), str(f)],
capture_output=True, text=True)
def _sidecar_args(rendered_yaml: str) -> list:
for doc in yaml.safe_load_all(rendered_yaml):
if not isinstance(doc, dict) or doc.get("kind") != "Deployment":
continue
for c in iter_containers(doc):
if c.get("name") == "oauth2-proxy":
return list(c.get("args") or [])
return []
if render_py.is_file():
# (6a) DECLARED ROUTES RENDER, IN ORDER, AND NOTHING ELSE DOES.
proc = _render("apirtdecl", 8121, ["^/api/", "^/signalr/"])
check(proc.returncode == 0,
f"the shipped catalog/render.py failed on a synthetic forward-auth fixture declaring "
f"two apiRoutes: rc={proc.returncode} {proc.stderr.strip()[:300]}")
if proc.returncode == 0:
got = api_routes_of(_sidecar_args(proc.stdout))
check(got == ["^/api/", "^/signalr/"],
f"the shipped renderer turned auth.apiRoutes ['^/api/', '^/signalr/'] into "
f"--api-route {got!r}. It must emit one repeatable --api-route per entry, in "
f"declared order. Note the flag is SINGULAR: --api-routes does not exist in "
f"oauth2-proxy v7.8.1 and the proxy REFUSES TO START with it, so a plural would "
f"crashloop every sidecar it rendered")
# (6b) AN APP THAT DECLARES NOTHING RENDERS NOTHING. The pihole case, proven on the
# generator rather than inferred from one committed golden.
for label, routes in (("absent", None), ("empty", [])):
proc = _render(f"apirtnone{label}", 8122, routes)
check(proc.returncode == 0,
f"the shipped renderer failed on a synthetic forward-auth fixture with apiRoutes "
f"{label}: rc={proc.returncode} {proc.stderr.strip()[:300]}")
if proc.returncode == 0:
got = api_routes_of(_sidecar_args(proc.stdout))
check(got == [],
f"the shipped renderer emitted --api-route {got!r} for a synthetic service "
f"whose auth.apiRoutes is {label}. A newly-onboarded forward-auth app must "
f"render NO --api-route: pihole serves no /api/, so a default would answer "
f"its page loads with 401 and the service would look permanently broken to a "
f"logged-out operator — a worse failure than the 302 bug the flag fixes")
# (6c) THE RENDERER REFUSES BREADTH. Asserted on the generator because render-golden runs
# on every gate invocation while the schema stage can be skipped.
for bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"):
proc = _render("apirtbad", 8123, [bad])
check(proc.returncode != 0,
f"the shipped renderer ACCEPTED auth.apiRoutes {bad!r} and rendered a sidecar. "
f"That regex matches paths a browser NAVIGATES to, so a plain page load answers "
f"401 and the operator is never offered a sign-in. The renderer must refuse it, "
f"so a breadth mistake fails render-golden even on a run where the schema stage "
f"did not execute")
# ============================================================ VERDICT
inv = " | ".join(f"{k} -> {v or '(none)'}" for k, v in sorted(rendered.items()))
print(f"oauth2-proxy sidecars audited ({len(rendered)}): {inv}")
if FAILURES:
print(f"\nCHECK FAIL (oauth2-proxy-api-routes-per-app): {len(FAILURES)} failed, "
f"{CHECKS - len(FAILURES)} passed", file=sys.stderr)
for f in FAILURES:
print(f" FAIL {f}", file=sys.stderr)
raise SystemExit(1)
print(f"check OK: oauth2-proxy-api-routes-per-app — {CHECKS} assertions (R-IDP-02)")
PY
@@ -129,6 +129,22 @@ KNOWN_EXTERNAL_MONITORS: dict[str, str] = {
"a PodMonitor, so it resolves against POD ports, not Service ports — the Flux controller "
"Deployments (and their `http-prom` container port) are installed by the Flux bootstrap "
"manifests, not authored here. Live on prod: job flux-system/flux-system, 6 targets up.",
"platform/oauth2-proxy/podmonitor.yaml:oauth2-proxy-sidecars":
"a PodMonitor, so it resolves against POD ports, not Service ports. Unlike the other "
"entries here the target is NOT chart-owned — the `metrics` (44180) container port is "
"authored in this repo, on the oauth2-proxy sidecar in apps/wave-0/{jackett,qbittorrent,"
"pihole}.yaml and apps/wave-1/{radarr,sonarr}.yaml. It is unauditable here because NO "
"Service exposes 44180 BY DESIGN: adding one would mean changing catalog/render.py and "
"every apps/wave-* render and golden, to publish an endpoint only Prometheus should dial. "
"A PodMonitor needs no Service, which is the whole reason it was chosen. What assertion "
"(2) would have checked was instead MEASURED on prod 2026-10-01, read-only: the pods "
"declare ports [('http', 4180), ('metrics', 44180)], the selector label "
"homelab.keithjackson.dev/auth-mode=forward-auth is on exactly those five gate pods, and "
"port-forwarding 44180 returns http=200 with 10208 bytes of oauth2_proxy_* metrics. "
"CAVEAT, deliberately recorded: those targets are DOWN on prod, because the app "
"namespaces' default-deny NetworkPolicy drops the scrape from ns monitoring (same bug and "
"fingerprint as platform/longhorn/networkpolicy-metrics.yaml documents). The port name "
"resolves; the network path does not yet.",
}
MONITOR_KINDS = ("ServiceMonitor", "PodMonitor")
@@ -171,6 +171,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): paas-demo-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paas-demo-allow-monitoring-scrape
namespace: paas-demo
labels:
app.kubernetes.io/name: paas-demo
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paas-demo
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): paas-demo-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -198,6 +198,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sonarr-allow-monitoring-scrape
namespace: sonarr
labels:
app.kubernetes.io/name: sonarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: sonarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -198,6 +198,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sonarr-allow-monitoring-scrape
namespace: sonarr
labels:
app.kubernetes.io/name: sonarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: sonarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -0,0 +1,289 @@
---
date: 2026-09-30
branch: feat/oauth2-proxy-auth-failure-visible
title: The Jackett auth failure was invisible because the gates emit nothing — alert on the gap, not on a metric that does not exist
---
## What changed
Jackett's UI broke in the way that costs the most: **HTTP 200 and a plausible page**. The
operator's report named the property that matters — *"reloading the page never fixes the issue and
the user is never re-prompted for login"*. Two sibling branches fix the auth defects. This one is
about the fact that **no signal existed anywhere**, and it turns out that is not an oversight in
the alert rules. It is an absence of data.
**The measurement that decided the whole design** (prod, live Prometheus, 2026-09-30):
count by (__name__) ({__name__=~"oauth2_proxy.*"})
oauth2_proxy_requests_in_flight => 2
Two series. Both from the **shared** forward-auth Deployment in the `oauth2-proxy` namespace.
That is every oauth2-proxy metric this cluster holds. The six per-app gate sidecars — crafty,
jackett, pihole, qbittorrent, radarr, sonarr — publish **nothing at all**: they run with
`--http-address=0.0.0.0:4180` and no `--metrics-address`, so oauth2-proxy's metrics listener never
starts; they declare only a `http:4180` container port; and no ServiceMonitor or PodMonitor in the
cluster selects them.
So the alert the brief asked for — *"a gated app is serving a sustained rate of auth redirects to
a user who is authenticated"* — **is not writable**, and writing it anyway would have produced
exactly the artefact this repo keeps getting burned by: a rule that is present, evaluating,
permanently silent, and indistinguishable from health. It was not written.
What was written instead alerts on the condition that made the incident invisible, from families
that **are** scraped:
- **`platform/oauth2-proxy/auth-visibility-alerts.yaml`** — `OAuth2ProxyGateUnobserved`:
count by (namespace) (kube_pod_container_info{container="oauth2-proxy"})
unless
count by (namespace) (up{container="oauth2-proxy"})
`for: 1h`, `severity: warning`. `kube_pod_container_info` is kube-state-metrics (a declared
owner in `alert-metrics-have-scrape-targets`) and sees the container whether or not it exports
anything; `up` is UNIVERSAL and carries a `container` label for any scrape target, so the rule
needs no advance knowledge of what job name a future per-app scrape will use. Nothing in it
touches `oauth2_proxy_*`, so it cannot be inert for the reason it is reporting. **It returns six
namespaces against live prod right now** and excludes `oauth2-proxy`, which is scraped. It
clears per-namespace as each gate gains a real scrape.
- **`platform/oauth2-proxy/auth-visibility-alerts.promtool-test.yaml`** — four states pinned:
fires for an unscraped namespace, never for the scraped one, honours `for: 1h` (pending at 59m),
and **stops** once a scrape appears under a *different* job name. Series shapes copied off live
prod, not invented.
- **`runbooks/oauth2-proxy-auth-failures.md`** — how to tell "the gate refused me" from "the group
check refused me" from "the app refused me" from "the XHR silently got HTML", with the
2026-09-30 jackett trace as the worked example. Registered in `runbooks/kustomization.yaml`'s
`configMapGenerator` and listed in `runbooks/README.md`.
- **`runbooks/README.md`** — a new row in *"Alerts you should not trust the silence of"* for the
per-app sidecars, plus the measured reason.
- **`platform/oauth2-proxy/README.md`** — what this component can and cannot see, and the exact
three-part change that would close the gap.
**Why the obvious alternative was rejected.** An alert on the *shared* proxy's
`oauth2_proxy_requests_total{code="302"}` ratio is the one a reader expects to see here. It would
need a new `SCRAPE_OWNERS` entry in `conformance/**` (a sensitive path needing human approval),
and — decisively — **it would not have fired on this incident**: the shared instance is not in the
data path for any sidecar-gated app, and its current pods export no `oauth2_proxy_requests_total`
at all, having proxied nothing since they started (the counter exists in the TSDB only from an
older ReplicaSet, `oauth2-proxy-78449b5657-*`, with `code` labels `302` and `401` and no `202`).
Shipping it would have looked like coverage of the reported failure and been none.
## What I verified
Everything below was measured against live prod through the pinned container, read-only. No
`apply`, no `edit`, no `patch`, no `flux reconcile`.
- **The full oauth2-proxy metric inventory.** `count by (__name__) ({__name__=~"oauth2_proxy.*"})`
→ `oauth2_proxy_requests_in_flight => 2`. `count by (__name__) ({job="oauth2-proxy"})` returns
go_*/process_*/promhttp_* plus that one family and nothing else. **Only the shared instance is
scraped**: `up{job="oauth2-proxy"}` → 2 targets, `10.0.1.189:44180` and `10.0.2.47:44180`, both
`1`, `namespace="oauth2-proxy"`.
- **The per-app sidecars are unscraped, and unscrapable as configured.** Every pod carrying an
`oauth2-proxy` container, with its declared ports: the two shared replicas have
`[('http',4180),('metrics',44180)]`; crafty, jackett, pihole, qbittorrent, radarr and sonarr all
have `[('http',4180)]` only. The live jackett sidecar's args contain no `--metrics-address`.
`kubectl get servicemonitors,podmonitors -A` lists 16 ServiceMonitors and 5 PodMonitors; the
only oauth2-proxy entry is `oauth2-proxy/oauth2-proxy`.
- **No existing alert is inert *for this reason*, because no alert references these metrics at
all.** `grep -rn oauth2_proxy --include=*.yaml` over the tree hits only cookie-name strings in
`platform/crafty/`. The 39 live PrometheusRules include `platform-health-identity`, whose four
rules are about Keycloak readiness and the client reconciler and are all keyed on `kube_*`
(scraped, fine). **The inertness here is the absence of a rule, not a broken one** — which is
why the runbook README's inert-alerts table gains a row phrased like the etcd one.
- **Keycloak cannot express it either.** `count by (__name__) ({__name__=~"keycloak_.*"})` →
empty, and empty over `[30d]`. The `keycloak-service` scrape yields JVM/Agroal/JGroups metrics
only; there are no user-event or login counters, so "a burst of re-logins for one client" is not
observable from the IdP side.
- **`oauth2_proxy_requests_total` has existed historically but not on the current pods.**
`count_over_time(oauth2_proxy_requests_total[30d])` returns three series, all from
`oauth2-proxy-78449b5657-*` (a prior ReplicaSet): `code="302"` on both replicas and `code="401"`
on one, ~634 samples each (~5h of a 30d window). **No `code="202"` ever.**
- **The alert expression fires against live data.** Run verbatim on prod:
`count by (namespace) (kube_pod_container_info{container="oauth2-proxy"}) unless count by
(namespace) (up{container="oauth2-proxy"})` → `pihole, jackett, crafty, qbittorrent, sonarr,
radarr`, value 1 each; `oauth2-proxy` correctly absent.
- **The log signal exists and is collected.** Loki (`loki-0`, SingleBinary) and a 3-node Alloy
DaemonSet are running, and Alloy labels pod logs with `namespace`/`container`. LogQL over 24h,
`{container="oauth2-proxy"}`: crafty 12036 lines, jackett 11669, oauth2-proxy 34559, pihole
11520, qbittorrent 11520, radarr 11763, sonarr 11520. Filtered to `|= "No valid authentication
in request"`: **crafty 81, jackett 39, radarr 67**; to `|= "AuthSuccess"`: **crafty 1, jackett
4, radarr 1**. Those ratios (81:1, 10:1, 67:1) are the incident's signature and are in the
runbook as the thing to compare against.
- **Why `kubectl logs` hid it.** The jackett sidecar's stdout is ~11.5k lines/day and almost all
of it is `GET "/ping" ... 200 2` from the kubelet probe, twice every 15s. A `--tail=40` shows
forty `/ping` lines and looks perfectly healthy. The runbook's commands all filter.
- **Runbook registration, both halves.** `- oauth2-proxy-auth-failures=oauth2-proxy-auth-failures.md`
is in `runbooks/kustomization.yaml`'s `configMapGenerator.files`, and the page is listed under
*Identity* in `runbooks/README.md`. The page clears the L0 quality floor (the four operator
sections, >1500 bytes, mentions `dev.sh` and `--context k3s-prod`), and the alert's
`runbook_url` points at exactly that name.
- **Gate:** run in the pinned toolbox from this worktree with the `ENGINE` shim. Exit code and
per-stage outcomes in the next section — read them there, not here.
## What I did NOT verify
- **The alert has never fired in Alertmanager.** Nothing is applied; the PrometheusRule exists
only in git on this branch. What is proven is that its expression returns the expected vector
against live prod data and that promtool agrees about its behaviour over synthetic series.
**Merging is what deploys it.**
- **`OAuth2ProxyGateUnobserved` would NOT have fired *during* the 2026-09-30 jackett incident in
any useful sense** — it describes a standing condition that has been true since the sidecars
were deployed, so it would have been firing before, during and after. That is deliberate and it
is the honest claim: it is a **gap tracker**, not an incident detector. It would have meant that
on 2026-09-30 an operator asking "why did nothing alert?" had the answer already on the board
instead of discovering it during the investigation. **No alert on this platform could have
detected the incident itself, and none can today.**
- **The alert that the brief actually asked for does not exist, and cannot be written yet.** The
sustained-redirect-ratio rule needs `oauth2_proxy_requests_total` from the *per-app* sidecars.
**Enabling that requires editing files owned by the two sibling branches** — the sidecar args in
`apps/wave-0/jackett.yaml` (Mira Vance) and `apps/wave-0/{qbittorrent,pihole}.yaml`,
`apps/wave-1/{radarr,sonarr}.yaml` (Theo Bright) — so it was **not attempted here**. The change
is three things per app, together: `--metrics-address=0.0.0.0:44180`, a `metrics` containerPort
plus Service port, and a ServiceMonitor. Once any one app has them, the rule belongs beside
`OAuth2ProxyGateUnobserved`, and that app's namespace drops out of it automatically.
- **The Loki alerting path is inert and I did not fix it.** Loki *has* a ruler stanza and the
chart's `loki-sc-rules` sidecar is running with `LABEL=loki_rule`, `FOLDER=/rules` — so a
ConfigMap-shipped LogQL rule would be delivered. But `GET /loki/api/v1/rules` answers **"unable
to read rule dir /var/loki/rules/fake: no such file or directory"** (the ruler reads a different
directory from the one the sidecar writes), and the rendered config contains **no
`alertmanager_url`** — `grep -i 'alertmanager\|rule_path'` over the `loki` ConfigMap returns
nothing. So a LogQL alert on `|= "No valid authentication in request"` — which *would* have
caught this incident, and is the single highest-value follow-up here — **would evaluate and
notify nobody**. Wiring it means `rulerConfig` (alertmanager_url + a matching rules directory)
in `platform/monitoring/loki.yaml`, which is not a file this brief gave me. **I did not ship an
inert rule ConfigMap to look productive.**
- **Nothing was reproduced in a browser.** The 302-to-200-with-HTML mechanism is read off the
sidecar access log and the response sizes (`/UI/Dashboard 200 44379`), not observed in devtools.
- **Loki retention is 72h** (`platform/monitoring/loki.yaml`), so the 24h ratios above are a
snapshot; anything older than three days is already gone. This is stated in the runbook because
it bounds what an operator can still investigate.
- **The gate's L2/L3 conformance did not run** (`CLUSTER_AVAILABLE` unset), so no runtime
requirement touched by this branch is certified. See the exit code below — **rc 3 is
UNDETERMINED, not green.**
### Follow-ups, in order of value
1. **Wire the Loki ruler to Alertmanager** (`platform/monitoring/loki.yaml`). This is the only
route to an alert that would have caught the actual 2026-09-30 failure, and the data is
already being collected. The rule is one expression:
`sum by (namespace) (rate({container="oauth2-proxy"} |= "No valid authentication in request" [15m]))
> 5 * sum by (namespace) (rate({container="oauth2-proxy"} |= "AuthSuccess" [15m]))`.
2. **Give one gate sidecar a metrics endpoint** and watch its namespace drop out of
`OAuth2ProxyGateUnobserved`. That is the cheapest proof the alert is a tracker and not a nag,
and it unlocks the ratio rule in Prometheus.
3. **Consider whether `--set-xauthrequest` apps should get `--api-routes`** so API paths answer
401 instead of 302. That is the fix that would make this failure *loud* rather than merely
observable, and it belongs with whoever owns the sidecar args.
---
# Round 2 — 2026-10-01: the scrape got wired, and found a bug in this branch's own alert
Added to the same PR (#29) rather than a fourth concurrent branch. PR #28 merged in the meantime
and `main` moved three PRs ahead; `origin/main` is merged in here, and the `runbooks/kustomization.yaml`
conflict was resolved by **keeping both** runbook entries (mine and `oauth2-proxy-session`).
## What changed
**`platform/oauth2-proxy/podmonitor.yaml` (new).** PR #28 gave the five fleet sidecars
`--metrics-address=0.0.0.0:44180` and a named `metrics` port. No Service exposes 44180 on any of
them, so a ServiceMonitor would have nothing to select — a **PodMonitor** targets pod ports directly
and needs no renderer change. Selects on the pod label `homelab.keithjackson.dev/auth-mode:
forward-auth`, which is on exactly the five fleet gate pods and absent from crafty's: the property,
not an app list, so a sixth forward-auth app needs no edit.
**`auth-visibility-alerts.yaml`: `up{container="oauth2-proxy"}` → `up{container="oauth2-proxy"} == 1`.
This is a real bug fix, and it is the most important thing in this round.** A target that is
discovered but failing still publishes `up`, with value **0**. The series exists, so
`count by (namespace)` returned 1, `unless` removed the namespace, and `OAuth2ProxyGateUnobserved`
would have **gone quiet while nothing was being scraped at all** — this branch's own disease,
reproduced inside the alert written to cure it. Not hypothetical: the PodMonitor lands five targets
whose scrape is dropped, so the bare form would have silently cleared on this very commit.
**Two new unit-test scenarios** pinning that: a discovered-but-failing target must still fire, and a
target that works and then breaks must re-fire rather than latch cleared.
**Docs corrected rather than appended to.** The 2026-09-30 claim "the sidecars export no metrics at
all" is now only half true and was rewritten in `runbooks/README.md`, `platform/oauth2-proxy/README.md`
and the alert header, each showing the two-step history, because each step looked like the end of it.
The runbook gains the policy-vs-no-target triage (step 1b) and the **radarr `/signalr` worked case**.
**Deliberately NOT shipped: the ratio alert.** `oauth2_proxy_requests_total` now exists on the
endpoint, but not in the TSDB, because the scrape is blocked. Writing the ratio rule today would make
it inert for precisely the reason it was refused on 2026-09-30. It becomes writable the day a scrape
succeeds, not the day a listener exists.
## What I verified
- **The endpoint serves — by payload, not by flag.** Port-forwarded to the jackett pod's 44180:
`http=200 bytes=10208`, exporting `oauth2_proxy_requests_in_flight`, `oauth2_proxy_requests_total`
(`code="200" 3`, `code="302" 2`) and `oauth2_proxy_response_duration_seconds`. `--metrics-address`
being in `args` and `/metrics` returning a body are different claims; both now hold.
- **The scrape path is DROPPED, measured from the right place.** From grafana in ns `monitoring`
(beside Prometheus, same policy position) to the jackett pod IP: `:44180` → `wget: download timed
out`; `:4180` → **also** timed out; the shared proxy's `:44180` → 200 with `oauth2_proxy_*` present.
Cause, read from the objects: ns jackett's `default-deny` is `podSelector: {}`,
`policyTypes: [Ingress, Egress]`, with an `egress` rule and **no `ingress` key** (deny-all ingress);
the only allowance is CNP `jackett-allow-gateway-ingress`, `fromEntities: [ingress]` to port 4180.
The shared proxy is scrapable only because ns `oauth2-proxy` has **no NetworkPolicy at all**.
- **No Service exposes 44180** on any of the five (enumerated).
- **The pod label selector is exact:** `auth-mode=forward-auth` on all five fleet gate pods, absent
from crafty's. Crafty's sidecar still has no `--metrics-address` (args read in full).
- **Flux ordering forbids shipping the fix from here:** `oauth2-proxy dependsOn=['platform-config']`
and `apps-wave-0 dependsOn=['platform-config']` — siblings, no ordering. A NetworkPolicy naming ns
`jackett` from this tier would apply today and fail on a cold rebuild.
- **The `== 1` guard is non-vacuous — proven by mutation.** Reverting it to the bare `up` makes the
two new scenarios FAIL (`got: nil` at 1h5m and 2h5m) and leaves every other assertion passing;
restoring it returns the suite to SUCCESS under pinned promtool v3.14.0.
- **Gate:** see the next section for the exit code and skips.
## What I did NOT verify
- **The PodMonitor has never been applied.** That it discovers the five pods is reasoned from the
measured pod labels and port name, not observed — no target list has been read with it in place,
because applying it is an operator action. **Expect `up == 0` on all five when it lands**, which is
the documented state, not a regression.
- **I did not fix the NetworkPolicy, and this PR therefore does not make the gates observable.** It
makes them *discovered*, and makes the remaining gap loud instead of silent. The one narrow rule
per app (ns monitoring → `auth-mode=forward-auth` → TCP 44180, per
`platform/longhorn/networkpolicy-metrics.yaml`) has to be rendered with each app's own policy, in
the catalog/renderer that is in flight on another branch. **It needs an owner.**
- **Whether Prometheus will actually select a PodMonitor in ns `oauth2-proxy` targeting other
namespaces.** `namespaceSelector.any: true` plus `podMonitorSelectorNilUsesHelmValues: false` and
the fact that cross-namespace ServiceMonitors/PrometheusRules already work here say yes; it is not
measured with this object.
- **No session-lifecycle claim can be made from the current window.** `AuthSuccess` is **0** on both
jackett and radarr since the PR #28 restart, so nobody has signed in and `--cookie-refresh=2m30s`
has never executed a refresh. The cookie fix is unproven in the one dimension that matters most.
- **The radarr `/signalr` finding is the coordinator's measurement, not mine** — I reproduced neither
the 206/209 sample nor the jackett 0-in-13,612. They are recorded as reported.
- **L2/L3 conformance still did not run** (`CLUSTER_AVAILABLE` unset). Nothing is certified.
### Round 2 addendum — CI is RED, by design, and the local gate cannot see why
After pushing round 2, `gate / gate (pull_request)` reported **failure after 1m42s** while the local
toolbox run was rc 3 with nothing failed. Measured cause, singular:
sensitive paths touched:
conformance/tests/l0-static/servicemonitor-ports-resolve/check.sh (matched conformance/**)
approving reviews: (no reviews at all) author=gitea-admin
`ci/approval-policy.sh` is fail-closed and exits 1 when a PR touches a `sensitive_paths` glob with no
`APPROVED` review from a non-author — "a change to the gate cannot be gated by the gate". The single
`KNOWN_EXTERNAL_MONITORS` entry is what trips it. **Nothing in L0/L1 failed.**
**The local gate structurally cannot catch this, and said `approval-policy: green (N/A)`.** The stage
short-circuits to N/A when `GITHUB_EVENT_NAME` is unset, which it always is off-CI. So for any branch
touching a sensitive path, a clean local gate is a FALSE REASSURANCE — the PR run is the only honest
signal. That is a general trap, not specific to this change, and it is worth knowing before someone
concludes a red PR means broken code.
To clear: a non-author approving review, then **re-run the gate** — approval does not re-trigger the
status check, so the required context stays red until the job runs again. `required_approvals=1`
already applied to every PR here, so the human was needed regardless; the conformance entry only
turns the tick red while that is pending.
The alternative is to drop the register entry, which means dropping `podmonitor.yaml` with it (the
check fails without the declaration). I did not take it: the entry is the "declare the limit"
mechanism the check explicitly asks for, it is shrink-only so it cannot rot quietly, and
`flux-podmonitor.yaml` is the same case already in the register. But it is a real trade and the
reviewer should make it knowingly.
@@ -0,0 +1,256 @@
---
date: 2026-10-01
branch: fix/admin-approval-override
title: Administrators can merge their own green PR — required_approvals 1 → 0, plus an attributed /approve-override for sensitive paths
---
## What changed
**The problem, measured first.** `required_approvals: 1` on `main` was a lock-out, not a
protection. Gitea refuses to let a pull request's author approve their own PR, and `jakman85` is
the **only** collaborator (permission `owner`). So no account in existence could approve a PR the
operator opened. Measured on the live API, 2026-10-01:
| | |
|---|---|
| PR #35 (`bugfix/arr-to-plex`, author `jakman85`) | `gate / gate (pull_request)` = **success, 1m41s** |
| its reviews | `[]` |
| live `required_approvals` | `1` |
| live `block_admin_merge_override` | `true` |
Green gate, zero reviews, no path to a merge — and the same was true of any PR that would have
fixed it.
It was also never the design this repo wrote down. `ci/approval-policy.sh`'s own header and
`ci/gate.sh:194` both already described the model as *"`required_approvals` drops to 0 and the
GATE decides"*, and `infra/gitea/require-gate.sh`'s closing note named this exact failure and
prescribed this exact remedy. `decisions/2026-09-30-flux-deploy-notification-inhibited.md` had
flagged the 1-vs-0 disagreement as unresolved and not that session's to settle. This settles it
at 0.
**Two layers were blocking, and both are addressed.**
*Layer 1 — Gitea branch protection.* `required_approvals: 1 → 0` in
`infra/gitea/expected-branch-protection.json`, with the rationale in the file, plus
`infra/gitea/allow-admin-self-merge.sh` to apply it to the live rule (it reads the target **from
the declaration**, so the script cannot drift from what the gate checks). `require-gate.sh` was
changed to 0 as well — left at 1 it would silently re-impose the lock-out on its next run.
The narrow choice matters: the *other* way to unblock an owner is
`block_admin_merge_override: false`, and that would have re-opened precisely what
`infra/gitea/block-admin-override.sh` was written to close (PRs #5–#9 merged with zero approvals
while the settings read as enforced). This change drops the approval **count** only.
`block_admin_merge_override` and `enable_status_check` both stay `true`, and the script asserts
both afterwards and fails loudly if the PATCH disturbed either. **A red gate remains unmergeable
by everyone, administrators included.**
*Layer 2 — `ci/approval-policy.sh`.* Sensitive paths (`ci/**`, `infra/gitea/**`, `CLAUDE.md`,
kyverno, rbac, sops, Flux wiring) still require a non-author `APPROVED` review, which the sole
administrator still cannot obtain. An administrator may now waive it by commenting on the PR:
/approve-override <reason>
- the directive must **start a line** (a quoted or mid-sentence mention does not count, or
repeating someone else's override would re-arm it);
- a **reason is mandatory** — a bare directive records that review was waived but not why;
- the commenter's permission is **re-checked live** against
`/collaborators/{login}/permission` when the gate runs, so an old comment from a demoted
account carries nothing;
- it is **invalidated by a new commit** (the comment must post-date the head commit), mirroring
Gitea's own `dismiss_stale_approvals`;
- the overrider, timestamp and reason are printed in the gate log and the job summary, and a
self-waiver by the PR's own author is called out as such.
Selection of the candidate comment is `ci/approval-override.py` — pure, offline, no authority of
its own — so it could be unit-tested; authorisation stays in the fail-closed stage.
`ci/tests/approval-override.test.sh` adds **23 assertions**, all passing, each constructing a
case the parser must refuse. Every new failure mode blocks: unreadable head-commit timestamp,
unreadable permission, parser error.
**The cost, stated rather than buried.** For an administrator, *"a change to the gate cannot be
gated by the gate"* is now **advisory, not structural**. That is unavoidable with one human — the
alternative is a repository its owner cannot change — and what is bought back is an attributed,
reasoned, commit-scoped record of each waiver instead of a silent bypass.
**The uncomfortable part, accepted with eyes open (operator decision).** Authorisation is by live
permission **alone**, with no allowlist of human accounts. The **agent account `gitea-admin` also
reports permission `owner`** on this repo (measured), so an agent *can* clear the sensitive-path
review on its own PR — and agents author most PRs here, so that is the common case, not an edge
case. I raised this before implementing; the operator chose permission-only. It is documented in
three places rather than left to be discovered: `approval-policy.json`
(`_known_consequence_agent_accounts`), the stage's header, and `CLAUDE.md`, which now tells
agents plainly **not** to use it on their own work. The narrowing path needs no code change —
add an `override_admins` list to `approval-policy.json` and the stage already requires membership
in addition to the permission check.
Also updated: `CLAUDE.md` §1 and the §2 enforcement table (the "approval required" row is now
explicitly ⬜ with the reason, rather than a stale ✅), and `infra/gitea/README.md` gains §6 and
marks its historical step 4 as reversed — do not re-apply.
**Second pass, 2026-10-02 — the override did not work the first time, and why.** The operator ran
the live PATCH (`required_approvals: 0` confirmed live, with `block_admin_merge_override` and
`enable_status_check` still `true`), merged PR #35, and posted `/approve-override Need to ensure
approval override work as expected` on PR #38. The gate re-ran (run 203) and **still blocked.**
The comment was not at fault: posted by `jakman85`, directive at the start of the line, reason
present, and 29 minutes **newer** than head commit `616a497`, so it cleared the staleness rule.
What failed was that **CI could not read it.** Run 203's log:
approval-override: COMMENTS_JSON is not a list
`SETTINGS_API_TOKEN` is minted `["read:repository", "read:admin"]` by
`infra/gitea/set-ci-token.sh`, and Gitea puts `/issues/{n}/comments` behind the **issue** scope. So
Gitea returned an error *object*, `jq -e .` accepted it as valid JSON, and the stage fell through
to "no approving review" — **a permission failure wearing the costume of a missing override.**
`/pulls/{n}/files` and `/pulls/{n}/reviews` are under `read:repository`, which is exactly why every
other part of the same stage worked and this one did not.
Three fixes, and the first is the one that mattered most:
1. **The diagnosis is no longer misleading.** `COMMENTS_JSON` is out of the must-parse-or-block
loop and is explicitly checked for being an *array*; when it is not, the stage prints Gitea's
own `.message`, names the missing scope, and says where to post the override instead. An
unreadable source can only cause a false BLOCK, never a false pass, so degrading it to a loud
warning is safe — and blocking on it was never the honest behaviour.
2. **The override is accepted from a PR REVIEW body as well as a comment**, so the hatch is not
hostage to one API scope. A review of type "Comment" works on the token CI has *today*. The
gate names which source it used. `PENDING` (unsubmitted, author-only draft) reviews are dropped.
3. **`set-ci-token.sh` now requests `read:issue`**, which makes the ordinary comment box work —
the thing anybody reaches for first. It takes effect only on a **re-run**: an existing token's
scopes cannot be widened in place.
`ci/tests/approval-override.test.sh` grows from 23 to **38 assertions**, covering the reviews
source, `PENDING` reviews, an error *object* from the comments API, "an unreadable comments source
must not suppress a valid review override", latest-wins across both sources, and that the
diagnostic actually quotes Gitea's message.
**One adjacent fix, deliberately in scope: `ci/tests/gate-decision.test.sh` was already broken.**
It hand-maintains a list of stub stage scripts that "must name EVERY stage `gate.sh` runs", and it
did not name `approval-policy` — added to `gate.sh` by PR #17 and never added here. `run_stage`
therefore got exit 127 from a missing file and recorded a FAILED stage in every case, so **15 of
50 assertions failed**, including every one expecting a PASS/UNDETERMINED headline. Measured on
pristine `origin/main` (`0c7e8dd`) before touching it, so the attribution is not a guess.
I fixed it because `make -C ci test` is the *only* runner for the suite this change adds — leaving
it red would have made the new tests unverifiable in practice. The file's own comment already
narrated this exact failure for `gitea-settings` and did not prevent the repeat, which says the
comment was not the fix. So the stub list is now **derived from `gate.sh`'s own `run_stage` lines**
and asserts it parsed at least 8 of them: a stage added to the gate can no longer be silently
missing from its test. `make -C ci test` goes from **FAILED to all suites passed**.
## What I verified
- **The lock-out is real, and PR #35 is the proof.** Queried live: head sha
`8a094c46`, statuses `gate / gate (pull_request) = success :: Successful in 1m41s`,
`/pulls/35/reviews` → `[]`, `/branch_protections/main` → `required_approvals: 1`. I read the
**rule object**, not `/branches/main`, per §2b.
- **`jakman85` is the only collaborator**: `/collaborators` → one entry. Permission `owner`.
- **`gitea-admin` also reports `owner`** — this is why the agent-account consequence above is a
statement of fact and not a hypothetical.
- **PR #35 touches no sensitive paths** (`apps/wave-0/plex.yaml`, `apps/wave-1/{radarr,sonarr}.yaml`,
`catalog/{radarr,sonarr}.yaml`, its decisions entry) — so `ci/approval-policy.sh` was never its
blocker. Layer 1 alone unblocks it.
- **API shapes verified before depending on them**, rather than assumed: `/git/commits/{sha}`
returns `.commit.committer.date` as RFC3339 **with an offset** (`2026-10-01T23:09:08-05:00`),
not a trailing `Z` — a parser handling only `Z` would have silently dropped every real
override, so there is a test for the offset form. PR conversation comments are under
`/issues/{n}/comments`; `/pulls/{n}/comments` returns diff comments only and would never have
seen an override.
- **`ci/tests/approval-override.test.sh`: 23 passed, 0 failed.** Covers the happy path, bare
directive, mid-sentence and `>`-quoted directives, stale and equal-timestamp overrides,
unparseable timestamps, missing login, invalid JSON, non-list JSON, latest-wins, offset
timestamps, and a configurable directive.
- `bash -n` clean on `ci/approval-policy.sh`, `infra/gitea/allow-admin-self-merge.sh`,
`infra/gitea/require-gate.sh`; `jq -e .` clean on both JSON declarations.
- **`gate-decision.test.sh`'s 15 failures are pre-existing, measured not assumed.** Extracted
`origin/main`'s `ci/` at `0c7e8dd` into a throwaway directory and ran the suite from there:
**FAIL, 15 failed / 35 passed**. After deriving the stub list from `gate.sh`: **PASS, 50
assertions**.
- **`make -C ci test` in the pinned toolbox: `ci/tests: all suites passed`, rc 0** — approval-override
**38**, gate-decision 50, touched-requirements-base 31, test-attribution 15, touched-requirements 26,
decisions-log pass. It was rc 2 (`ci/tests: FAILED`) before the stub-list fix.
- **The live flip worked, and the drift detector proved it.** Run 202 (before): `gitea-settings:
FAIL`, `required_approvals: DECLARED 0 but LIVE is 1`. Run 203 (after the operator ran the
PATCH): `gitea-settings: pass`, with `block_admin_merge_override = true` among the keys it
checked. So the narrow change held — the approval count moved and the gate's teeth did not.
- **The first override attempt failed for a measured reason, not a guessed one.** Run 203 log:
`approval-override: COMMENTS_JSON is not a list`. Token scopes read from `set-ci-token.sh`:
`[read:repository, read:admin]`. The same run successfully read `/pulls/38` and
`/pulls/38/files` (author and sensitive paths both resolved) and got past the reviews
validation, which is how I know `/pulls/{n}/reviews` IS readable with that token — from
behaviour, not from documentation.
- **The comment itself was valid.** `created_at` 2026-10-02T05:31:24Z vs head commit `616a497`
committer date 2026-10-02T00:02:20-05:00 → the comment is 29m04s newer, so it passed the
staleness rule. Ruling the comment out is what left the token scope as the only candidate.
- **`?limit=1000` is not the culprit.** Queried `/issues/38/comments` with `limit=1000`, `limit=50`
and no limit using my own credentials: `type=array` in all three. So the difference is the
credential, not the query.
- **The root cause is confirmed by Gitea, in its own words.** Run 206 (the fix in place):
`[warn] cannot read PR COMMENTS from Gitea — token does not have at least one of required
scope(s), required=[read:issue], token scope=read:admin,read:repository.` followed by the
instruction to post the override as a review. That is the diagnosis the first attempt should
have produced, and `gitea-settings: pass` in the same run. The stage still ends `FAIL`, correctly
— there is no valid override on the PR yet.
- **Toolbox merge gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`), run on the
rebased branch: **rc 3, `MERGE GATE: UNDETERMINED`**. Green stages: spec-immutability,
decisions-log, lint, l0 (23 offline cases, 0 failures), render-golden including the new
`catalog/render.py --selftest`, l1 (3 cases), approval-policy (N/A offline). Skipped:
`gitea-settings` (no `SETTINGS_API_TOKEN` on this host) and `conformance` (no cluster).
**This is NOT a green run and I am not reporting it as one** — rc 3 is UNDETERMINED per the
CLAUDE.md §3 contract.
## What I did NOT verify
- **The override has still never been ACCEPTED by a real run.** This is the second attempt at
this bullet, which is itself the point: the first pass called the hatch unverified, and the
first real test found a defect that no amount of local testing would have (an API scope CI has
and I did not). What is now measured is that the parse → *reject* path works end to end and
diagnoses itself. The **accepting** branch — directive found → live permission lookup → exit 0 —
has never executed. Posting the override as a **review** on PR #38 is its first real test, and
I would not be surprised by a third round.
- **`read:issue` is the right scope name — Gitea said so itself, so this is no longer a guess.**
Run 206 printed Gitea's own message: `token does not have at least one of required scope(s),
required=[read:issue], token scope=read:admin,read:repository`. That both confirms the root
cause and names the string, which matters because this repo has been burned by a token *name*
that could not exist (`GITEA_SETTINGS_TOKEN`). What is **still** unverified is that the token
*mint* accepts it — `POST /users/{u}/tokens` with that scope has not been run. The review route
does not depend on it.
- **I did not verify that Gitea lets a PR's own author submit a `COMMENT`-type review.** Gitea
blocks author self-*approval*; "Comment" is expected to be allowed, and that expectation is now
load-bearing for the recommended route. It is one click to find out, and I deliberately did not
test it by posting an override myself: doing so as `gitea-admin` is precisely the agent
self-override that `CLAUDE.md` now forbids.
- **The whole re-run mechanism is manual.** `gate.yml` triggers on `pull_request` and `push: main`
only — there is no `issue_comment` trigger, so posting an override does **not** re-run the gate.
Re-run the job from the Actions UI, or close and reopen the PR (which fires `pull_request` and
leaves the head SHA alone). **Do not push an empty commit to re-trigger it:** that moves the head
commit and makes the override stale by the rule above, so it would have to be posted again. A
comment trigger was considered and not built — it would need to check out PR head code while the
job holds `SETTINGS_API_TOKEN`, which is the classic pwn-request shape and not worth it here.
- **I have not seen the gate still REFUSE a merge** now that approvals are 0. The claim that
`block_admin_merge_override: true` keeps a red gate unmergeable rests on Gitea's documented
behaviour and on the 2026-09-29 session, **not** on a provoked refusal in this one. That probe
is written out at the foot of `allow-admin-self-merge.sh` and should be done, exactly as the
push rejection was provoked rather than assumed.
- **Clock skew in the staleness check is unhandled.** Staleness compares the comment's
server-side `created_at` against the head commit's *committer* date, which comes from the
committer's machine. A commit dated in the future would reject legitimate overrides until real
time catches up (fail-closed, so loud, but annoying). Gitea's own `dismiss_stale_approvals`
compares SHAs instead; a comment cannot. Not hit here — PR #35's commit date is ~21 min behind
server time — and not fixed.
- **`ci/tests/approval-override.test.sh` does not run in the merge gate.** `ci/gate.sh` runs no
`ci/tests/*`; they run only via `make -C ci test`. So a regression in the parser would not be
caught by CI. PR #33 (merged while this branch was being written, and rebased onto here) fixed
the *same class* of gap for `catalog/render.py --selftest` by invoking it from
`ci/render-golden.sh` — it did **not** wire `ci/tests/*` in, which I confirmed after rebasing
rather than assuming. Wiring them is a one-line stage and I did not widen scope to it.
- **Authorisation itself is untested code.** `ci/approval-override.py` is unit-tested; the live
permission lookup, the `override_admins` allowlist branch, and every new BLOCK path in
`ci/approval-policy.sh` need Gitea and have never been exercised. The allowlist branch in
particular is implemented but **unused** — `override_admins` is absent by design.
- **The other open PRs will go red** once the live value changes, until they are rebased onto the
merged declaration: as of writing those are **#29** and **#37** (both `gitea-admin`), plus
**#35**. I did not rebase them. Merging them first avoids it entirely; the script's header gives
the order. PRs #33, #34 and #36 merged while this branch was being written — this branch is
rebased onto `0c7e8dd`, so the list was re-measured rather than carried forward.
- No cluster state was read or changed; nothing here touches Flux, Kyverno or any workload.
+120
View File
@@ -0,0 +1,120 @@
---
date: 2026-10-01
branch: bugfix/arr-to-plex
title: Radarr and Sonarr may now reach Plex on 32400 — the network path only, not the Plex connection itself
---
## What changed
Radarr and Sonarr had no route to Plex. The media library lives on NFS
(`/mnt/Storage/plex`, the ZFS dataset `Storage/plex` on `${BULK_NFS_SERVER}`), and NFS
does not deliver filesystem CRUD notifications, so Plex never learns that an arr has
written a new file. Without a push from the arrs, the library only reflects new media
when somebody scans it by hand. That is the bug this branch addresses.
The fix is one `dependencies:` entry per app, added to the catalog — the source of truth:
- to: plex
port: 32400
protocol: tcp
in both `catalog/radarr.yaml` and `catalog/sonarr.yaml` (these two *are* the arr suite
here; `jackett`, `qbittorrent` and `seerr` are different roles and are untouched).
The renderer expands each entry into a **pair** of Cilium policies, which is why a
three-line catalog change produces 88 lines under `apps/`:
| Side | Policy | File |
|---|---|---|
| egress | `radarr-allow-plex-32400` | `apps/wave-1/radarr.yaml` |
| egress | `sonarr-allow-plex-32400` | `apps/wave-1/sonarr.yaml` |
| reciprocal ingress | `plex-allow-radarr-32400` | `apps/wave-0/plex.yaml` |
| reciprocal ingress | `plex-allow-sonarr-32400` | `apps/wave-0/plex.yaml` |
Three commits, and the middle two are worth recording because both were self-inflicted
gate failures rather than design changes:
- `753c48b` — the catalog edit alone, with `apps/` left unrendered.
- `9048c2f` — re-rendered `apps/` from the catalog, which `render==golden` requires.
- `58f02fe` — removed 20 trailing spaces on `catalog/radarr.yaml:103`, left behind as
comment-alignment padding when the comment moved down to the `port:` line. Sonarr's
equivalent line kept its comment in place, which is why only radarr tripped.
## What I verified
**The lint failure, root-caused.** Gate run 193 on `9048c2f` failed with
`[fail] lint: FAILED -> blocks merge`, and lint was the only failing stage. yamllint
emitted 419 findings, but **418 were `line-length`**, which `ci/yamllint.yaml` pins at
`level: warning` and which therefore cannot change the exit code. The single error-level
finding in the entire repository was:
catalog/radarr.yaml
103:13 error trailing spaces (trailing-spaces)
`trailing-spaces` inherits the default severity (error), so that one line returned rc 1.
After the fix, `git grep -nIE ' +$' -- '*.yaml' '*.yml'` matches nothing repo-wide.
**Gate run 195 on `58f02fe`** (read from the job log, not from the status tick):
[ ok ] approval-policy: green [ ok ] l0: green
[ ok ] gitea-settings: green [ ok ] render-golden: green
[ ok ] decisions-log: green [ ok ] l1: green
[ ok ] lint: green
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
[fail] Stages that did not run: spec-immutability
The Gitea check reports `success` / "Successful in 1m41s", but the gate's own verdict is
**UNDETERMINED (rc 3)**, not PASS. That tick is `gate.yml` tolerating exit 3 for the one
stage it documents as unverifiable on this runner (the spec repo is not checked out
there). Nothing else produced a 3.
**`render==golden` green** on `58f02fe`, so `apps/` is a faithful canonical render of
`catalog/` — the four generated policies are not hand-written drift.
**DNS egress was already open** and is not a new gap: `apps/wave-1/radarr.yaml` carries
an existing allow for port 53 UDP+TCP, so resolving the Plex service name does not need
anything added here.
**Live branch protection on `main`**, measured against
`/api/v1/repos/Home-Network/home-network-k3s-impl/branches/main` rather than assumed:
`protected: true`, `user_can_push: false`, `enable_status_check: true`,
`status_check_contexts: ['gate / gate (pull_request)']`, `required_approvals: 1`.
## What I did NOT verify
**This change does not accomplish what the PR set out to do.** It opens the network path
and nothing else. Triggering a library update also requires each arr's
*Settings → Connect → Plex Media Server* entry plus a Plex token, and **none of that
exists anywhere in `catalog/` or `apps/`** — I grepped both for `plex`, `token` and
`connect` and found only NFS paths, a uid and a timezone. Radarr's and Sonarr's config
lives on a 5Gi Longhorn PVC mounted at `/config`, so that connection is UI-only state
that exists solely on the PVC and never in git, in the same way Crafty's local login
does. **Until somebody adds it in each app's UI, Plex still will not see library
updates, and this branch will look like it did nothing.** That follow-up is not tracked
by any file in this PR.
**L2/L3 conformance never ran.** `CLUSTER_AVAILABLE` is unset on the runner, so the
eight requirements this diff touches — `R-NET-01`, `R-NET-05`, `R-NET-06`, `R-NET-07`,
`R-IDP-02`, `R-IDP-06`, `R-GITOPS-02`, `R-PLAT-02` — are **UNVERIFIED**. That gap is
unusually load-bearing here: the entire substance of this change is four
CiliumNetworkPolicies, and no test has confirmed that traffic from the `radarr` or
`sonarr` namespaces actually reaches `plex:32400`. The green gate proves the offline
render subset only.
**`spec-immutability` did not run** (`R-TEST-06` not verified), for the documented
reason — the sibling spec repo is absent from the runner.
**Nothing was checked on the cluster.** I ran no `kubectl`, so I did not confirm that
the `plex` Service exposes 32400 as the arrs would address it, that the pod labels the
policies select (`app.kubernetes.io/name`, `k8s:io.kubernetes.pod.namespace`) match the
running pods, or that Cilium accepted the four new objects. Flux has not applied any of
this: the branch is unmerged, so `.status.lastAppliedRevision` does not contain it.
**I did not verify Plex's own side** — whether its library-update endpoint on 32400 is
the right target for the arrs' notification, or whether Plex requires
"Update my library automatically" enabled to act on one.
**I am not the approver and did not approve this PR.** `required_approvals: 1` is live,
and the only credential on the authoring workstation belongs to the PR's author
(`jakman85`), whose own review Gitea does not count. Per CLAUDE.md §1 the merge is a
human action regardless.
@@ -0,0 +1,482 @@
---
date: 2026-10-01
branch: feat/oauth2-proxy-api-route-per-app
title: The config model had no way to say "this app's API lives here" — so --api-route could not ship. Now it can, per app.
---
## What changed
Second half of PR #28 (`decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md`), which
fixed the cookie collision, the 5-minute session and the missing metrics listener in one renderer
function and **deliberately declined `--api-route`** for a reason I have preserved rather than
worked around: that flag was measured on **one** app, and `PiHole serves no /api/ at all`, so a
renderer-wide constant would apply one app's path shape to five. The `auth` block is
`additionalProperties: false`, so there was no per-app override hook. **Creating the hook was the
work; setting the flag is the easy part.**
**Three surfaces, in order of what they protect:**
| Surface | Change | What it guards that the others cannot |
|---|---|---|
| `schemas/servicedefinition.schema.json` (**1.8.0 → 1.9.0**) | new optional `auth.apiRoutes`: array of anchored path regexes, `uniqueItems`, default `[]` | rejects a too-broad regex **where a human types it** — `ci/l0.sh` validates `catalog/*.yaml` against this schema on every gate run |
| `catalog/render.py` | emits one `--api-route=<r>` per declared entry, in order; **raises** on an unanchored entry | `ci/render-golden.sh` runs on every gate invocation even when the schema stage skips |
| `conformance/tests/l0-static/oauth2-proxy-api-routes-per-app/` (new) | cross-checks each rendered sidecar against its own ServiceDefinition | catches a **renderer constant**, which neither of the above can see |
**The `auth` block stays `additionalProperties: false`.** One named, documented key was added; the
door is not opened. That property is the reason the gap existed, and it is worth keeping — it is
what made "there is no override hook" a *fact* rather than a *guess* for the two engineers who hit
it before me.
**Rebased mid-flight.** `origin/main` advanced from `08deb20` to `cdf3e14` while I was working
(PR #27 and PR #32, the `hardware.gpu: intel-quicksync` capability). **That change also took schema
`1.8.0`**, so mine is renumbered to **1.9.0** and chained behind it; both version entries are
present in the description, and `catalog/render.py` had a two-sided conflict in the constants block
which is resolved by keeping both (the `DRI_*` constants and `OAUTH2_API_ROUTE_PATTERN` are
unrelated). Re-rendering after the rebase still changes **only** jackett and radarr — plex's new
golden reproduces byte-identically, so the two changes do not interact.
**Shape:** a **list** of regexes, not a single string, because Radarr needs two
(`^/api/` and `^/signalr/`) and `--api-route` is natively repeatable. A single string would have
forced alternation into the value, and alternation is exactly how breadth gets smuggled past a
prefix-only reading of a regex.
**The anchoring ratchet.** Each entry must match `^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$` — anchored at
`^/`, then literal path characters, with an optional trailing `.*` or `$`. The same pattern is in
the schema, in the renderer, and in the new check, and assertion (5) of the check **fails if they
drift apart**, because if they do the loosest one wins silently. Rejected shapes, all five
provoked: `^/`, `^/.*`, `.*`, `/api/` (unanchored), `^/api/|^/` (alternation). Alternation is
**refused rather than supported**: the flag is repeatable, so one entry per route costs nothing.
**Why breadth is the dangerous direction, and it is not symmetric.** A *missing* `--api-route` is a
silent 302 — bad, invisible, recoverable. A *too broad* one answers a plain **page load** with 401:
a logged-out operator gets a bare error page and is **never offered a sign-in**, so the service
looks permanently broken and no reload recovers it. That is a worse outcome than the defect the
flag fixes, which is why it is gated on three surfaces rather than documented in one.
### Also folded in: the selftest could exit 1 with ZERO diagnostics
Not my change originally — Desmond Oyelaran found it while wiring `catalog/render.py --selftest`
into the `render-golden` stage (his PR #33, which touches only `ci/render-golden.sh` and is **not**
in my base; `origin/main` at `cdf3e14` does not yet drive the selftest from any gate stage). The
coordinator asked me to fold the fix into this PR because the defect is in the file I own.
`_selftest()` reached `_cookie_name()` through a bare `next(genexpr)` with **no default**. Delete
the `--cookie-name` emission — exactly the mutation that assertion exists to catch — and it raises
`StopIteration`, which escapes `_selftest()` **before its collected `SELFTEST FAIL:` lines are
printed**. Right verdict, no diagnostics, and it masks every other assertion the same mutation
broke. Fixed with `next(..., None)` plus an explicit `check()` so a missing flag reports **as a
named assertion**.
Provoking it turned up **three more instances of the same failure class** in the same block, which
is why this is not literally a one-line change: `side = fa_containers[1]`,
`side["env"][0][...]`, and the `fa_svc` lookup all raise rather than assert when the sidecar is
absent entirely. All four are now None-tolerant, and the two fixtures I added for `--api-route`
were written the same way from the start. Measured, on throwaway copies:
| Mutation | Before | After |
|---|---|---|
| `--cookie-name` emission deleted (origin/main's renderer) | rc 1, **0** `SELFTEST FAIL` lines, 1 `StopIteration` | rc 1, **3** `SELFTEST FAIL` lines, 0 tracebacks |
| the oauth2-proxy sidecar not appended at all | rc 1, **0** lines, `IndexError` | rc 1, **20** `SELFTEST FAIL` lines, 0 tracebacks |
Note the consequence of his stage change for anyone reading this later: once PR #33 lands,
`.gitea/workflows/gate.yml:138` runs `./ci/gate.sh || [ "$?" -eq 3 ]`, so **rc 1 is not tolerated
in CI the way rc 3 is** — a selftest failure will block. Which is the right outcome, and it is why
a selftest that prints nothing was worth fixing before that lands rather than after.
### Declined, with reasons: the metrics-ingress NetworkPolicy
The coordinator asked for my judgement on bundling Ines Dubois's finding — that #28's metrics
listener genuinely serves (she measured `http=200`, 10,208 bytes,
`oauth2_proxy_requests_total{code="302"} 2` via port-forward) but the scrape will be **dropped**,
because namespace `jackett`'s default-deny has **no `ingress` key at all** and
`jackett-allow-gateway-ingress` admits `fromEntities: [ingress]` only. The fix belongs in each
app's own rendered policy, i.e. in my file and my goldens. **I declined to bundle it**, and asked
for it as the immediate next PR. Five reasons, in the order that decided it:
1. **The real reason is that it is a MODEL question, not a policy to bolt on.** Schema 1.6.0
already owns this: `observability.metricsEndpoint` renders the Service port **and** the
monitoring-namespace ingress allow. What the model cannot currently express is a **second**
metrics endpoint on the same pod belonging to a *sidecar the renderer injected*, rather than to
the workload the operator declared. Keying a bespoke policy off the pod label
`homelab.keithjackson.dev/auth-mode: forward-auth` works today, but it hardcodes "forward-auth
implies a metrics port on 44180" into a policy instead of **deriving** it from the sidecar
declaration that is already the single source of that port — the exact "list, don't derive"
anti-pattern `oauth2-proxy-cookie-names-distinct` assertion (5) exists to forbid, and which I
would be introducing one directory away from the check that forbids it. That deserves its own
review, and it is squarely the kind of decision I was brought in to make deliberately.
2. **Different subject, and it is a trust-boundary change.** Mine is the auth *response code* on
API paths (R-IDP-02). That is an ingress allow (R-NET-01/02, R-OBS-01) admitting a new
namespace to a new port. Bundling a security-relevant ingress rule into a PR whose headline is
`--api-route` is how such a rule gets waved through on the strength of the other half — and this
PR already needs a human for a *different* sensitive-path reason, so the second subject would be
reviewed in the shadow of the first.
3. **The conflict argument does not actually apply to my diff.** It assumes I am regenerating five
goldens; **I am regenerating two** (jackett, radarr), because the flag renders only where
declared. A metrics-ingress policy renders a *different document* — a CiliumNetworkPolicy — in
all five. Different hunks, in files git merges independently; and goldens regenerate
deterministically from the renderer, so the follow-up rebases onto `main` cleanly. The N-1
rebase problem `decisions/README.md` records was two branches appending to **the same hunk**,
which this is not. I checked my own diff before answering rather than accepting the premise.
4. **Every fact in the finding is hers and I have measured none of it** — not the 200/10,208 bytes,
not the `wget: download timed out` on `:44180` *and* `:4180`, not the Flux sibling ordering that
makes `oauth2-proxy` and `apps-wave-0` unordered. Shipping a security-adjacent rule on wholly
inherited evidence, as a footnote in someone else's PR, is precisely what CLAUDE.md §2b exists
to stop. In its own PR I would measure it first.
5. **Sequencing: the PodMonitor is in HER PR #29, not mine.** If I ship the policy and her
PodMonitor is reworked or delayed, I have opened an ingress allow for a scraper that selects
nothing — a hole with no consumer. The policy is only useful paired with the PodMonitor, which
argues for it landing adjacent to that, not here. It also drags in a
`KNOWN_EXTERNAL_MONITORS` entry in `conformance/tests/l0-static/servicemonitor-ports-resolve/`,
a second conformance interaction on top of the new check this PR already adds.
Diff size is the tiebreaker rather than the argument: **1,245 insertions across 13 files**,
including a ~400-line new conformance check. I would rather hand a reviewer one subject they can
actually falsify. **Queued, not dropped** — and if the next person takes it, the model note in (1)
is the part worth arguing about, not the YAML.
### Which apps got which routes, and the measurement behind each
| App | `auth.apiRoutes` | Evidence |
|---|---|---|
| **jackett** | `^/api/` | 2026-09-30, its own access log: **33 × 302 against 2 × 401** on `/api/` paths, and **4 of 6 POSTs answered by the proxy and never reaching Jackett** (upstream field `-`, not `/`), including both `/api/v2.0/server/update` calls |
| **radarr** | `^/api/`, `^/signalr/` | `^/signalr/`: measured **by me, on the live post-fix pod** — see below. `^/api/`: measured on radarr by the PR #30 author, on the **pre-fix** pod |
| **pihole** | *(none — deliberate)* | serves no `/api/`; **0** unauthenticated requests in its current pod's 23 h |
| **sonarr** | *(none — not measured)* | 13,643 log lines, **0** unauthenticated requests. No traffic, not a negative result |
| **qbittorrent** | *(none — not measured)* | 13,649 log lines, **0** unauthenticated requests. Same |
**`^/signalr/` on radarr is the live proof this change was needed**, and it is the strongest
evidence in this entry. 23 h after PR #28 deployed, on `radarr-7d55cbffb9-42hmh` (0 restarts), the
sidecar logged **232 unauthenticated requests across the pod's entire lifetime, and all 232 are the
same call**: `POST /signalr/messages/negotiate`, browser UA, **302**, upstream `-` (Radarr never saw
one of them). That is a **left-open browser tab** whose session the cookie rename orphaned, polling
SignalR forever, silently redirected every time, and **never re-prompting the operator**. It is the
reported symptom — "reloading never fixes it and I am never re-prompted" — still happening in
production, on a service nobody was looking at. With 401 the UI's connection breaks **loudly**; the
operator reloads; `/` matches neither regex, so the reload signs them back in.
**`^/api/` on radarr: included, and here is the honest basis.** The current pod has logged **zero**
`/api/` traffic, so I did **not** re-measure it. It rests on a measurement of **radarr** recorded in
the merged PR #30 handoff — its 156 unauthenticated requests were `/api/v3/command` (62),
`/api/v3/queue/details` (46) and `/signalr/messages/negotiate` (43), i.e. **108 of 156 were
`/api/v3` XHRs**. That is this app's own evidence, by another engineer, not an analogy to another
app. I include it because `/api/v3/` is a pure JSON namespace a browser never navigates to (Radarr
serves its UI at `/`, assets under `/Content/` and `/MediaCover/`), so the breadth risk is nil and
the measured benefit is 70% of that app's rejection volume.
**Sonarr and qBittorrent were measured and deliberately left alone.** Sonarr is nearly the same
codebase as Radarr, which is exactly what makes `^/api/` + `^/signalr/` a tempting guess and still a
guess. I read both pods' logs: **zero** unauthenticated requests, nothing but health probes, because
nobody has opened either UI since the cookie fix rolled. **That is evidence of no traffic, not
evidence of no need.** Each catalog file now carries a comment saying so, with the numbers and what
to measure before adding the routes.
### Goldens and fixtures regenerated
Rendered with the canonical command
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`).
**Two goldens changed, not five** — and that is the per-app property proving itself on the real
tree rather than in a fixture:
apps/wave-0/jackett.yaml + --api-route=^/api/
apps/wave-1/radarr.yaml + --api-route=^/api/
+ --api-route=^/signalr/
`apps/wave-0/pihole.yaml`, `apps/wave-0/qbittorrent.yaml` and `apps/wave-1/sonarr.yaml` are
**byte-identical to `main`**. **No L1 fixture needed regenerating**: all three
`conformance/tests/l1-rendering/*/fixtures/svcdef*.yaml` declare `auth.mode: forward-auth` with no
`apiRoutes`, so they render no flag and their goldens are unchanged. PR #28 touched 5 goldens and 3
L1 fixtures because it changed flags on *every* sidecar; this one changes flags only where a
measurement says so, which is the whole point.
### The L0 decision (and it is a decision, not a default)
**Not** in `oauth2-proxy-cookie-names-distinct`. That check's subject is cookie **identity** — its
inputs are names and domains, its failure mode is mutual session eviction, its register is the
shared proxy's claim on the default name. `--api-route` shares none of those. Bundling them would
make that check's name a lie and couple two unrelated ratchets, so a future api-route decision could
only be expressed by weakening a *cookie* check. That is PR #28's own stated discipline, applied to
my change rather than quietly dropped.
**A sibling check, `oauth2-proxy-api-routes-per-app`** (whole-repo scope, so it runs on every gate
invocation via `ci/l0.sh`'s `--include-scope` union — no bespoke stage, which
`whole-repo-invariants-wired` would fail). **Nowhere was the wrong answer for exactly one reason:**
the existing gates cannot see a renderer constant. `render-golden` compares the render to the
goldens, so a constant **plus regenerated goldens agree with each other while both being wrong** —
I provoked that and it regenerates with **rc 0**. The new check's assertion (2) compares each
rendered sidecar against its **own ServiceDefinition**, which is the only reading that fails it.
## What I verified
- **`--api-route` is SINGULAR, read off the PINNED binary rather than taken from the brief.**
`podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --help`:
`--api-route strings return HTTP 401 instead of redirecting to authentication server if token
is not valid. Format: path_regex`. There is **no `--api-routes`** in the help output. The PM asked
me to verify rather than trust, and that is the result — the singular form is confirmed and the
help text also confirms the *semantics* I am relying on (401 instead of a redirect).
- **The premise, in-cluster, read-only, through the pinned toolbox, `--context k3s-prod` named
explicitly** (CLAUDE.md §2b and §5). PR #28 **is** deployed: all five sidecars carry
`--cookie-name=_oauth2_proxy_<app>`, `--cookie-domain=<app>.home.keithjackson.dev`,
`--cookie-refresh=2m30s` and `--metrics-address=0.0.0.0:44180`. Pods, all **0 restarts**:
`jackett-6877fcd9f5-ht9d9`, `pihole-…`, `qbittorrent-5547bdb9bd-zq557`,
`radarr-7d55cbffb9-42hmh`, `sonarr-ddf485775-8ckwt` (started 2026-09-30T21:41–21:42Z).
- **The full post-fix log census, parsed rather than grepped** (oauth2-proxy's access-log format,
with `/ping` health probes excluded), over each pod's whole life:
app lines unauth AuthSuccess removing session cookie sig invalid
jackett 13,649 0 0 0 0
pihole 13,649 0 0 0 0
qbittorrent 13,649 0 0 0 0
radarr 14,099 232 0 0 0
sonarr 13,643 0 0 0 0
Three findings, kept separate:
1. **The collision fix works.** Jackett was 39 unauthenticated before; it is **0** in 13,612
non-probe lines. The eviction fingerprint (`removing session` / `cookie signature not valid`)
is **0 on all five**, where radarr previously had 8 and 8.
2. **Radarr's 232 are one request.** 100% `POST /signalr/messages/negotiate`, 100% **302**, 100%
upstream `-`, browser UA (`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537…`). **Zero `/api/`
traffic of any kind** on this pod.
3. **0 `AuthSuccess` anywhere**, so nobody has signed in since the restart and `--cookie-refresh`
is **still** unexercised — unchanged from PR #28's own caveat.
- **Only two goldens change, and three are byte-identical to `main`.** `git status` after the
canonical render lists exactly `apps/wave-0/jackett.yaml` and `apps/wave-1/radarr.yaml`.
`render-golden` is green.
- **`catalog/render.py --selftest`: PASS**, with new assertions that (a) the `fa` fixture, which
declares nothing, renders **no** `--api-route`; (b) a fixture declaring two routes renders exactly
those two, in order; (c) the renderer **refuses** all five breadth shapes; (d) four legitimate
routes (`^/api/`, `^/signalr/`, `^/api/v3/.*`, `^/api/v2.0/server/update$`) are **accepted** — a
ratchet that blocks real API prefixes is a ratchet someone eventually deletes.
- **The new check: rc 0, 43 assertions, 6 sidecars audited**, with its inventory printed so the
reviewer can see what it read:
`jackett -> ['^/api/'] | pihole -> (none) | qbittorrent -> (none) | radarr -> ['^/api/',
'^/signalr/'] | sonarr -> (none) | crafty -> (none)`.
- **The check was tested in BOTH directions, on throwaway copies outside the repo**, never by
mutating the branch. Each mutation fired its own assertion class and nothing else:
- **Pre-change tree** (`git archive origin/main` + the test dropped in): **rc 1, 11 failures** —
no service declares routes, the renderer emits none, the renderer does not read `auth.apiRoutes`,
the pattern is absent from both renderer and schema, and the shipped renderer accepts all five
breadth shapes.
- **pihole's golden hand-edited** to carry a route it never declared: **rc 1, 2 failures** (the
catalog↔golden mismatch, and "declares nothing must render nothing").
- **radarr's golden widened** to `--api-route=^/`: **rc 1, 2 failures** (unanchored, plus the
mismatch).
- **Renderer turned into a constant and EVERY golden regenerated to match: the regen exits 0** —
i.e. `render-golden` is **happy** — and the new check exits **1 with 15 failures**, naming
pihole, qbittorrent, radarr and sonarr individually. **This is the case that justifies the
check's existence** and I provoked it rather than argued it.
- **Schema `items.pattern` deleted**: **rc 1, 1 failure** — the three-surface drift guard.
- **Unmutated control: rc 0.**
- **The selftest diagnostics fix was PROVOKED, not reasoned.** Both mutations above were run
against throwaway copies of the renderer outside the repo (`origin/main`'s `render.py` for the
"before" column, mine for the "after"), never by mutating the branch. The pre-fix StopIteration
and the post-fix 3-and-20 named assertions are quoted from those runs. `--selftest` on the
unmutated branch: **PASS**. Goldens after the fix: **unchanged** (it touches only `_selftest()`).
- **The check is selected by the whole-repo union.** `./conformance/run.sh --layer l0 --requirements
r-nosuchreq-99 --include-scope whole-repo --print-selection` lists
`tests/l0-static/oauth2-proxy-api-routes-per-app` among **21** tests (it was 20), i.e. it runs
even when the touched-requirement filter matches nothing.
- **THE SCHEMA GUARD IS PROVEN WITH THE PINNED VALIDATOR `ci/l0.sh` ACTUALLY DRIVES**, not by
reading the pattern. `./schemas/validate.sh` (`check-jsonschema, version 0.29.4`) against seven
throwaway ServiceDefinitions written outside the repo:
good ["^/api/", "^/signalr/"] rc 0
bad-root ["^/"] rc 1 apiRoutes ... does not match
bad-dotstar ["^/.*"] rc 1 apiRoutes ... does not match
bad-unanchored ["/api/"] rc 1 apiRoutes ... does not match
bad-alt ["^/api/|^/"] rc 1 apiRoutes ... does not match
bad-everything [".*"] rc 1 apiRoutes ... does not match
bad-mode ["^/api/"] on mode: oidc rc 1 const 'forward-auth'
So a too-broad regex is rejected **where a human types it**, on every gate run, by the real tool
— and the shape invariant fires too.
- **THE RENDERER'S REFUSAL REACHES `render-golden` AS A READABLE FAILURE, NOT A TRACEBACK.** With
`^/signalr/` swapped for `^/` in a *copy* of the catalog (outside the repo), both invocation
shapes exit **rc 1 with 0 tracebacks** — the single-SD form and `--all <dir> -o <dir>
--wave-layout --no-image-automation`, which is the one `ci/render-golden.sh` drives:
render.py: radarr: auth.apiRoutes entry '^/' is not an anchored path regex. Each entry
must start with '^/' and continue with literal path characters (e.g. '^/api/',
'^/signalr/', '^/api/v3/.*'). ... A too-broad --api-route answers a PAGE LOAD with 401, so
a logged-out operator sees a bare error page and is never offered a sign-in: the service
looks permanently broken rather than merely unauthenticated.
So the breadth guard is gated by `render-golden` even on a run where the schema stage skipped,
and the operator is told *why* rather than handed a stack trace.
- **The schema's new shape invariant.** A non-empty `auth.apiRoutes` requires
`auth.mode: forward-auth`, because the flag renders onto the per-app sidecar and no sidecar exists
for `oidc`/`native`/`none` — declaring it elsewhere would be a **silent no-op**, which is the
failure class this repo keeps paying for.
- **Gate:** toolbox `./ci/gate.sh` with the two-mount ENGINE shim — exit code and stage list in the
next section, because it is not a pass and that belongs there.
- **No mutating cluster access.** `kubectl get/logs/jsonpath` only, `--context k3s-prod` named on
every call. No `apply`/`edit`/`patch`, no `flux reconcile`. No Secret `.data` or `.stringData` was
read or printed; I did not need to touch a Secret at all this session. The one container I ran
locally was the pinned oauth2-proxy image, to read its `--help`.
- **`~/.git-credentials` was never read.** PR creation used `git credential fill`, which does not
echo the value.
## What I did NOT verify
- **NO BROWSER ROUND-TRIP. BY ANYONE. FOR THIS FLAG.** This is the most important line here. The
302/401 split, the dropped POST bodies and the orphaned SignalR poll are all read from access
logs; that a **401 repairs** the operator's experience is reasoned from HTTP semantics, not
watched. Nobody has added a Jackett indexer or recovered a Radarr tab through this
configuration. The human test below is the first real evidence this change will ever have.
- **Local gate: rc 3 (UNDETERMINED), not 0.** Green: `spec-immutability`, `decisions-log`, `lint`,
`l0`, `render-golden`, `l1`. **Skipped:** `gitea-settings` (no `SETTINGS_API_TOKEN` locally) and
**L2/L3 conformance** (`CLUSTER_AVAILABLE` unset). Nothing FAILED; nothing runtime was certified
either. **rc 3 is UNDETERMINED, never green.**
- **MY LOCAL `approval-policy: green` WAS MEANINGLESS, AND I ALMOST REPORTED IT AS EVIDENCE.**
`ci/approval-policy.sh:47` reads `GITHUB_EVENT_NAME`, which is unset outside CI, so the stage
short-circuits at line 50: `ok "not a pull_request event (event='none') — approval policy N/A"`.
**A clean local gate therefore CANNOT catch a sensitive-path problem — structurally, not by
accident.** Flagged to me by the coordinator from Ines Dubois's round, and I verified it by
re-running the gate with `GITHUB_EVENT_NAME=pull_request PR_NUMBER=34`: the stage then
**evaluates** and fails closed on the missing token —
[fail] no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews.
[fail] stage approval-policy: FAIL
[fail] approval-policy: a sensitive change lacks a non-author approval -> blocks merge
[fail] MERGE GATE: BLOCKED (rc 1, not 3)
— while **every other stage stays green** (`spec-immutability`, `decisions-log`, `lint`, `l0`,
`render-golden`, `l1`). That is the useful result: it bounds the CI failure to this one stage
rather than leaving "something is red" as the whole report. Fail-closed is correct here and
deliberate: unlike `gitea-settings`, where a missing credential is a lock-out, a skip of this
stage would be a bypass (CLAUDE.md §1).
- **CI, read from each commit's `/statuses` rather than from a tick.** Both contexts report, and
**only one of them runs**. Identical on `99560c2d`, `df1b25b6` and `f1d16f44`:
gate / gate (pull_request) state=failure desc='Failing after ~1m43-1m45s'
gate / conformance (pull_request) state=success desc='Has been skipped'
**That second line is §2b's trap in the wild**: a *skipped* Gitea job reports
`state=success`. The conformance job did not execute, so its green tick certifies nothing —
read the description, not the colour. The duration is given as a range on purpose: it differs
by a second or two per run, and pinning one number inside the file whose commit triggers the
next run is a self-reference, not a measurement. The load-bearing fields are the **state** and
the **description**, and those are stable across all three.
Worth recording that the **same context changed its answer over time**: on the first push it
briefly read `state=pending desc='Blocked by required conditions'` — still waiting on the gate
job it depends on — and resolved to `success` only once that job settled. So sampling a status
once can show either a pending **or** a green tick for a job that never executed.
**I could NOT read the CI job log to confirm the failing stage from CI's
own output**: this Gitea's `/api/v1/repos/{owner}/{repo}/actions/runs` endpoint answers
**404** (only `/actions/tasks` exists, which lists tasks without logs), and
`/actions/jobs/{task_id}/logs` answers `404 Not found` for the task id. So my attribution of the
CI failure to `approval-policy` rests on the local event-set reproduction above plus the
sensitive-path rule, **not** on CI's own log. That is weaker evidence than PR #28 had and I am
naming it rather than borrowing its wording.
- **Branch protection and the runner, RE-MEASURED this session against the live API** (§2b),
not inherited from CLAUDE.md §2's table:
GET /repos/Home-Network/home-network-k3s-impl/branches/main
protected True
user_can_push False
enable_status_check True
status_check_contexts ['gate / gate (pull_request)']
required_approvals 1
GET /admin/actions/runners
id=1 name=incluster-dind status=online
So `required_approvals=1` applies to **every** PR here regardless of path — this needs a human
even without the sensitive-path rule. I did **not** provoke a rejected push to `main` this
session; `user_can_push: False` is the API's word, which is exactly the weaker form of evidence
CLAUDE.md §2 warns about, and I did not strengthen it.
- **`^/api/` ON RADARR IS NOT MY MEASUREMENT.** It is PR #30's, taken on the **pre-fix** pod. The
current pod has logged **zero** `/api/` traffic in 23 h, so I could not reproduce it. I judge the
breadth risk nil (`/api/v3/` is pure JSON; the UI is at `/`), but if someone wants a strictly
self-measured configuration, drop `^/api/` and keep `^/signalr/` — the 232 are entirely SignalR.
- **SONARR AND QBITTORRENT ARE UNMEASURED, NOT CLEARED.** Zero traffic in 23 h is zero evidence.
qBittorrent's WebUI drives everything through `/api/v2/` and is *plausibly* affected the same way
Jackett is; Sonarr is nearly Radarr's codebase. **Both are left alone on purpose** — plausibility
is what put a renderer constant on the table in the first place. Expect to add `^/api/` to
qBittorrent and `^/api/` + `^/signalr/` to Sonarr once someone opens those UIs and records the
split. **This PR does not fix them.**
- **PiHole is asserted to serve no `/api/` on inherited evidence, not mine.** Its log shows 0
unauthenticated requests, which says nothing about what paths it serves. The claim comes from
PR #28/#30. Note the pinned `update.range` is `>=2024.0.0`, and PiHole v6 *does* introduce a REST
API at `/api/` — so if PiHole is ever upgraded across that line, this needs re-measuring. It is
still correct to declare nothing today, because declaring an unmeasured route is the mistake.
- **`--cookie-refresh=2m30s` has STILL never been exercised** — 0 `AuthSuccess` on all five pods.
PR #28 named this as the fix most likely to need a second pass and it remains unobserved. My
change does not touch it, but a sign-in during the human test below will exercise both at once,
so a failure there must be attributed carefully.
- **The regexes are not validated against the apps' real route tables.** I assert `/api/v3/` and
`/signalr/` are JSON/RPC namespaces a browser never navigates to from Radarr's and Jackett's
observed traffic and from how those UIs are built — **not** from reading their routing code or
enumerating their endpoints. A path I have not thought of that matches `^/api/` and *is*
browser-navigated would 401.
- **`OPTIONS` preflight and non-browser clients are unconsidered.** `--skip-auth-preflight` exists
and is not set; nothing in these logs shows a CORS preflight, and Sonarr/Radarr talk to Jackett
**directly on `:9117`**, bypassing the proxy entirely — so I do not expect impact. Unverified.
- **The live pods do not carry this change and nothing re-measures that they will.** git is not the
cluster (§2b). **Merging is what deploys**, and it will roll **jackett** and **radarr** only
(`strategy: Recreate` on RWO Longhorn volumes, so a brief gap per app, and radarr/sonarr lose
direct `:9117` access to Jackett while its pod restarts). Existing sessions survive — no cookie
flag changed — so unlike PR #28 nobody is signed out.
- **No L2/L3 assertion reads any of this.** There is no runtime check that a `/api/` request to a
gated app answers 401, and I did not write one; `CLUSTER_AVAILABLE` is unset so it could not run
in CI anyway. The whole guard is static.
- **`catalog/render.py --selftest` is UNGATED IN MY BASE, and I did not wire it.** Desmond's PR #33
does that (`ci/render-golden.sh`) and is in review, not merged — `origin/main` at `cdf3e14`
contains no stage that drives it, which I checked rather than assumed (`grep -n selftest
ci/render-golden.sh` → no match). **So until #33 lands, my new selftest assertions are
dev-time only**, and the *gated* half of my breadth ratchet is the schema `pattern` (via
`ci/l0.sh`'s schema stage), the renderer's own `raise` (via `render-golden`), and the new L0
check. I deliberately did not wire the selftest myself: #33 already does it, and duplicating it
here would collide with his branch in the one file he owns. Note the two PRs are **independent
but complementary** — his makes the stage real, mine makes its output readable when it fires.
- **I did NOT verify PR #33's own claims.** The 106-assertion count, the
selftest→stage→`gate.sh`→`BLOCKED` propagation chain, and the `--cookie-refresh 2m30s → 10m`
mutation being caught by the selftest but not by `render-golden` are all his measurements,
relayed to me by the coordinator. I reproduced only the StopIteration defect itself, because that
is the part I was asked to fix. If his branch is reworked, nothing in mine depends on it.
- **The new check does not read the spec repo** and asserts nothing about `../home-network-k3s`
(R-TEST-06, read-only). `schemas/renderer-contract.md` is updated in **this** repo only; the
canonical spec `docs/13-service-catalog.md` is **not** updated, because it is immutable from here.
Someone with spec write access should mirror `auth.apiRoutes` into docs/13 and docs/15.
- **THE OAUTH2-PROXY SIDECARS' METRICS ARE STILL UNSCRAPABLE, AND THIS PR DOES NOT FIX IT.** See
the declined-bundle section above. #28 shipped `--metrics-address` as a prerequisite and said
plainly that it was not scraped; Ines has now measured that the listener **serves** and that the
scrape is **dropped by each app namespace's default-deny**. Nothing in my change touches that.
Anyone reading this entry as "the fleet gates are observable now" is wrong, and the next PR is
where that changes.
- **Alternation is refused, and that is a judgement I may have to revisit.** No app in this catalog
needs it, and the flag is repeatable — but if some future app genuinely needs a regex the ratchet
rejects, the fix is to widen the pattern **with a reason** on all three surfaces at once, not to
delete the ratchet on one.
- **CI status: two contexts, and only one of them ran.** Quoted in the PR body and in the report to
the PM. `approval-policy` **fails by design** until a non-author approving review exists, because
the diff touches `conformance/**`, which `infra/gitea/approval-policy.json` lists as sensitive
("the tests and the harness; changing them changes what green MEANS"). That is CLAUDE.md §1
working, not a defect. `required_approvals=1` applies to every PR here regardless of path, so this
needs a human either way.
### The human test — and how to tell a too-broad regex from a working one
After merge, wait for **new** pod names at 2/2 in `jackett` and `radarr`.
1. **The breadth test FIRST, before anything else, from a signed-out browser** (private window).
Open `https://radarr.home.keithjackson.dev/` and `https://jackett.home.keithjackson.dev/UI/Dashboard`.
**Each must bounce through Keycloak and land on the app's UI.** If either shows a bare
`401 Unauthorized` instead of a sign-in, **the regex is too broad — stop and revert.** That is
the one failure this change can introduce, and it is the only test that can see it. A working
regex is invisible on a page load; a broken one is total and immediate.
2. **Jackett, the original report:** signed in, *+ Add indexer* → pick one → *Configure* → *Okay*.
**It must save.** Previously 4 of 6 POSTs never reached Jackett at all.
3. **Radarr, the live symptom:** open Radarr and leave the tab open. DevTools → Network, filter
`signalr`. Today those negotiate calls are **302** and the UI never complains. They must now be
**200** while signed in — and if the session is ever lost, **401**, with the UI showing a visible
connection error instead of polling silently forever.
4. **The loud-failure test:** in DevTools, delete the `_oauth2_proxy_radarr` cookie and let the tab
poll. The negotiate call must answer **401** and Radarr must *show* a problem. Then **reload** —
`/` matches neither regex, so the reload must sign you back in silently. That round trip is the
entire fix: fail loudly on the API, recover on the page load.
5. **The six-minute test (PR #28's open item, still open):** stay on a dashboard for >6 minutes,
then save something. That exercises `--cookie-refresh=2m30s` past the 300 s access-token cliff.
If it fails, suspect the realm's refresh/SSO-idle timeouts — not this change.
6. **If something still fails, it must now fail LOUDLY.** A silent success-looking no-op means the
change did not take: check the running pod's args for `--api-route` before assuming anything
else.
@@ -0,0 +1,205 @@
---
date: 2026-10-01
branch: fix/render-selftest-runs-in-gate
title: catalog/render.py's 106 assertions were dev-time only; the gate now runs them and blocks on them
---
## What changed
Single subject, one file: **`ci/render-golden.sh` now invokes `catalog/render.py --selftest`**
and exits 1 when it fails. Nothing else. No renderer change, no new conformance test, no new
gate stage.
This closes the top follow-up of `decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md`:
> Nothing in `ci/` or `.gitea/` runs `catalog/render.py --selftest` — I grepped for it. So the
> twenty-odd assertions in that selftest, including the six I just added, are dev-time only: a
> future edit that drops either flag *and* re-renders the goldens would pass the entire merge
> gate.
Re-measured here and confirmed: before this commit, `grep -rl -- --selftest` over `ci/` and
`.gitea/` returned **nothing** for the renderer. The selftest executes **106** assertions
(`check()` invocations counted at runtime via `sys.settrace`, without editing `render.py`; 109
static call sites, three of them on failure-only branches). All 106 were unexercised by the
merge gate.
### The lead was WRONG, and here is why
The brief named `conformance/tests/l0-static/auditability-proofs-producible/check.sh:291` as the
one-line fix. That line is real and it does drive selftests:
for tool in ("record_run.py", "status.py"):
r = run([sys.executable, str(LIB / tool), "--selftest"])
But it is **the wrong hook**. That loop is section (d) of a check that serves **R-SEC-03**, and
its subject is the *evidence chain* — can a self-skipped run, or a stale ledger row, become an
audit proof? `record_run.py` and `status.py` are **in** that chain; `catalog/render.py` is not.
Hanging the renderer there would have made the check's name, its `intent` annotation and its
requirement mapping (`R-SEC-03`) all untrue, and a future decision about the renderer could then
only be expressed by weakening a *security-auditability* check. That is the precise coupling the
cookie-collision entry refused for its own check, and it applies here.
The second option that entry offered — a new scope-declaring L0 test — is the supported mechanism
for a **whole-repo invariant** (`ci/l0.sh` Stage 3b's `--include-scope whole-repo` union, ratcheted
by `tests/l0-static/whole-repo-invariants-wired`). It is the right shape for an invariant
quantified over the tracked tree. It is the wrong shape for *"a script in this repo must pass its
own unit test"*: it would need to declare requirements it does not serve, and it would be two new
files of several hundred lines by this tree's conventions for what is a six-line invocation.
**The honest home is `ci/render-golden.sh`** — the gate stage whose subject *already is*
`catalog/render.py`. It already resolves `$PYTHON` and `$RENDER_PY`, already reports rc 3 when
either is missing, already exits 1 on renderer error, and `ci/gate.sh` already maps it through
`for s in lint l0 render-golden l1` where a FAIL is BLOCKED and a SKIP is UNDETERMINED. The
stage's two assertions are independent and both are needed:
- `render-golden` catches golden-vs-renderer drift, but is satisfied by **any** renderer whose
output was re-rendered — including one that just lost a contract invariant and had all ten
goldens regenerated in the same commit. That is exactly the hole named in the quote above.
- `--selftest` catches that, and is blind to golden drift.
The file header and the stage name were updated so neither is a lie: the stage is now "the
renderer is trustworthy", with two numbered assertions, and the step summary emits its own
`- render-selftest:` line so the gate output still NAMES what ran.
The selftest runs **before** the empty-catalog `exit 3` on purpose: it renders synthetic
ServiceDefinitions in memory and is catalog-independent, so an empty catalog must not be able to
skip it.
`catalog/render.py` itself was **not touched** (owned by a concurrent branch this session), and
neither was any file under `conformance/`.
## What I verified
- **The selftest runs and passes: rc 0, `render.py selftest: PASS`, 106 assertions executed.**
Counted at runtime, not from the source: a `sys.settrace` hook counting `check()` calls, in a
throwaway script outside the repo, with no edit to `render.py`.
- **The stage passes with it wired in**, host run of the single stage:
[gate] Stage: render==golden reproducibility (canonical render vs committed apps/)
[gate] Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)
[ ok ] render.py --selftest passed
[ ok ] render==golden: all 10 onboarded service golden(s) reproduce from the canonical render
rc **0**. The golden comparison still reports all 10 goldens, so the new invocation did not
displace what the stage already asserted.
- **IT CAN FAIL — provoked twice, against a mutated COPY of the renderer, never against the
tree.** `ci/render-golden.sh` already honours `RENDER_PY`, so each mutant lived in
`/tmp/claude-1000/desmond-selftest/` and the repo's `catalog/render.py` was never modified —
which matters, because that file belongs to a concurrent branch this session.
- **Mutation 1 — the regression this selftest was written for.** Deleted the
`f"--cookie-name=_oauth2_proxy_{s['name']}"` emission from `_oauth2_sidecar_container()`,
i.e. reverted the fleet-wide cookie-collision fix of PR #28. Stage rc **1**. Note the shape
of the failure, because it is a real (minor) weakness and is listed as a follow-up: the
selftest died with an uncaught `StopIteration` out of `next(a for a in container["args"] if
a.startswith("--cookie-name="))` at `render.py:2641`, rather than printing its collected
`SELFTEST FAIL:` lines. It is correctly non-zero and the stage correctly blocks, but the
operator gets a traceback instead of the assertion message.
- **Mutation 2 — a clean, named assertion failure.** `OAUTH2_COOKIE_REFRESH = "2m30s"` ->
`"10m"`, widening the refresh past the measured 300s access-token lifetime. Stage rc **1**:
[fail] catalog/render.py --selftest FAILED — the renderer violates its own output contract.
[fail] Every golden under apps/ is produced by this renderer, so the goldens below cannot
[fail] be trusted even if they reproduce byte-for-byte. Reproduce with:
[fail] python3 catalog/render.py --selftest
SELFTEST FAIL: --cookie-refresh=10m parses to 600.0s, which is not inside (0, 300s). A
refresh at or past the access-token lifetime never renews in time, which is the same
logout it was added to stop
This is the mutation that proves the wiring's value most directly: mutation 2 would **not**
be caught by `render-golden`'s diff if the goldens were regenerated in the same commit, and
it is one of the two flags the cookie-collision entry explicitly recorded as **ungated**.
- **THE NON-ZERO PROPAGATES ALL THE WAY TO `gate.sh`'s rc — provoked, in the pinned toolbox, not
reasoned about.** `RENDER_PY=/desmond-selftest/render-mutant2.py ./ci/gate.sh`:
[ ok ] stage spec-immutability: pass
[ ok ] stage decisions-log: pass
[ ok ] stage lint: pass
[ ok ] stage l0: pass
[gate] Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)
[fail] catalog/render.py --selftest FAILED — the renderer violates its own output contract.
SELFTEST FAIL: --cookie-refresh=10m parses to 600.0s, which is not inside (0, 300s). ...
[fail] stage render-golden: FAIL
[ ok ] stage l1: pass
...
[fail] render-golden: FAILED -> blocks merge
[fail] MERGE GATE: BLOCKED
`gate.sh` rc **1**. Three links in the chain, each measured: selftest rc 1 -> stage rc 1 ->
gate rc 1 / `MERGE GATE: BLOCKED`. This mattered enough to provoke rather than assume: this
repo has a documented habit of exit codes getting flattened, and
`.gitea/workflows/gate.yml:138` runs `./ci/gate.sh || [ "$?" -eq 3 ]`, which tolerates rc 3 —
so a stage that degraded a failure to a skip would be swallowed by CI. rc 1 is not tolerated
there, and rc 1 is what this produces.
- **`spec-immutability` genuinely RAN and passed, it did not skip.** The two-mount worktree shim
plus `-e SPEC_REPO=/home-network-k3s` (the fix recorded in the 2026-09-30 cookie entry) works:
`stage spec-immutability: pass`, not SKIPPED. R-TEST-06 is therefore actually covered by these
runs.
- **No collision with PR #29.** The brief warned that `feat/oauth2-proxy-auth-failure-visible`
had added checks under `conformance/tests/l0-static/`. Measured —
`git diff origin/main...origin/feat/oauth2-proxy-auth-failure-visible --name-only` — it touches
**no `conformance/` path at all**: `decisions/`, `platform/oauth2-proxy/` (a promtool test, an
alert rule, its kustomization), `runbooks/`. Disjoint from this branch either way, since this
branch touches only `ci/render-golden.sh` and this file.
- **Priya's files were not touched.** `catalog/render.py`, `schemas/servicedefinition.schema.json`,
`apps/wave-*` and `conformance/tests/l1-rendering/` are all untouched by this branch — confirmed
by `git diff --name-only`, which lists exactly two paths.
- **`catalog/render.py --selftest` was the LAST unwired selftest in the tree.** Measured:
`grep -rl -- --selftest` across the repo finds selftest-capable artifacts in exactly four
places, and after this commit every one is driven by the gate — `conformance/lib/record_run.py`
and `conformance/lib/status.py` by `auditability-proofs-producible/check.sh:291`, the Keycloak
client-secret reconciler (embedded in `platform/keycloak/client-secret-reconciler.yaml`) by
`keycloak-client-secret-reconciler/check.sh:633`, and `catalog/render.py` by this change.
## What I did NOT verify
- **Local gate: rc 3 (UNDETERMINED), NOT 0. This is not green and must not be reported as
green.** Measured in the pinned toolbox on `eeb6211`:
[ ok ] spec-immutability: green
[ ok ] approval-policy: green
[ ok ] decisions-log: green
[ ok ] lint: green
[ ok ] l0: green
[ ok ] render-golden: green
[ ok ] l1: green
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
[fail] Stages that did not run: gitea-settings
Nothing FAILED. The single cause is `gitea-settings` **SKIPPED** — `SETTINGS_API_TOKEN` is not
set in my environment, so branch protection and runner registration were not read; CI has that
token and the stage is expected to run there. **L2/L3 `conformance`** also skipped
(`CLUSTER_AVAILABLE` unset) but is a *declared* gap that `gate.sh` treats as not-applicable
rather than undetermined, so it is not what produced the 3. Touched requirements
**R-GITOPS-01 R-TEST-02 R-TEST-03 R-TEST-06** are therefore **UNVERIFIED at L2/L3**, all four.
The stage this branch changes, `render-golden`, is green **and** was independently proven able
to turn the whole gate red (above).
- **`approval-policy` passed LOCALLY only because there is no pull_request event**
(`not a pull_request event (event='none') — approval policy N/A`). That is not evidence that it
passes. This diff touches `ci/**`, a sensitive path, so in CI the stage is expected to **FAIL**
until a non-author approving review exists. That is §1 working, fail-closed, not a defect.
- **The selftest's own quality is unchanged and ungated.** I wired its *invocation*; I did not
review its 106 assertions, and nothing ratchets them. Deleting the body of `_selftest()` would
still pass this stage, because an empty selftest returns 0. The repo already has the right
answer for that — `conformance/tests/l0-static/keycloak-client-secret-reconciler/mutations.py`
injects regressions one at a time and requires `--selftest` to catch every one — and applying
that pattern to `catalog/render.py` is the follow-up, deliberately not done here: it is a
second subject, it touches `conformance/`, and it would need to live beside the renderer whose
file another branch owns this session. **Until it exists, "the selftest runs" is proven and
"the selftest is falsifiable" rests on my two hand-run mutations, which are not in the gate.**
- **Mutation 1's traceback is not fixed.** `render.py:2641`'s `next(...)` has no default, so the
absence of `--cookie-name` raises `StopIteration` and the selftest's collected `SELFTEST FAIL:`
lines are never printed. Correct verdict, poor diagnostics, and it suppresses the other
assertions that mutation also broke. The one-line fix (`next(..., None)` plus a `check()`) is in
`catalog/render.py`, which is another branch's file this session, so it is a follow-up and not
a thing I touched.
- **Nothing about the live cluster.** No `kubectl` of any kind was run on this branch — not even
read-only; this change is offline CI wiring and needed none. No `apply`/`edit`/`patch`, no
`flux reconcile`. Merging this deploys nothing: it changes only what the gate refuses.
- **CI's own run of this branch.** Quoted in the PR body from the commit's `/statuses`, with
descriptions read rather than colours. A *skipped* Gitea job reports `success` with
"Has been skipped" and certifies nothing.
- **`vars.CLUSTER_AVAILABLE` is still unset in CI**, so even a fully green PR proves the L0/L1
offline subset and nothing about runtime behaviour.
- **I did not re-measure branch protection, required approvals, or the runner.** Those are
CLAUDE.md §2 claims and §2b says re-measure before relying on them; this branch relies on none
of them beyond the PR flow itself, and I read the gate's own output rather than the API.
@@ -0,0 +1,149 @@
---
date: 2026-10-02
branch: fix/plex-quicksync-verification
title: Hardware transcoding works — and `runbooks/plex-quicksync.md`'s verification section was wrong
---
## What changed
**Hardware transcoding is confirmed working on prod.** Measured 2026-10-02 from
`Plex Media Server.log` in the running pod: `vaapi` on `/dev/dri/renderD128`
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
`zero-copy support present`, `using zero-copy transcoding`, and
`final decoder: vaapi, final encoder: vaapi`. VAAPI for **both** directions, zero-copy, so frames
never round-trip through the CPU. The device-plugin mechanism from PR #37 works end to end.
**And `runbooks/plex-quicksync.md`'s verification section was wrong, which is the correction this
branch exists to publish.** The page said the Plex dashboard showing `Transcode (hw)` was "the *only*
proof available, and nothing else will ever tell you". The operator had hardware transcoding fully
working while **Dashboard → Activity showed nothing of the sort** — so the page's authoritative check
was silent on a working system, and sent someone looking for a fault that did not exist. Three
inference errors are now corrected on the page, two of them recorded as named false leads so the next
reader does not re-walk them:
1. **The dashboard is not proof.** Replaced as the primary check by Plex's own log — the grep is in
the page, in its **redacting** form, because `Plex Media Server.log` carries API URLs containing
`X-Plex-Token` (CLAUDE.md §6; a credential has been leaked into a transcript here before). The
page also now says explicitly that **`Plex Transcoder Statistics.log` is the wrong file** — it
holds XML `<Variant .../>` records, not ffmpeg output, and the name invites the mistake — and why
`grep -a` is required. The dashboard is **kept but demoted** to a secondary, best-effort signal,
with its absence stated to prove nothing.
2. **An fd snapshot is not evidence.** A `/proc/*/fd` sweep inside the pod found no holder of
`renderD128` while hardware transcoding was active; transcode sessions are short and the scan
landed in a gap. Recorded as a documented false lead, with the reliable process-level check in its
place: the transcoder's **argv** (`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128`), found via
`ps -eo pid,user,comm | grep -i transcoder`.
3. **A missing preference key does not mean the setting is off.** The page told readers to treat an
absent `HardwareAcceleratedCodecs` in `Preferences.xml` as "never enabled". With hardware
acceleration demonstrably on and working, that key was **absent from all 27 attributes** while
`HardwareDevicePath` **was** written and the log said `hardware transcoding: enabled`. First-command
#4 now reads both keys and states plainly that `HardwareDevicePath` present is a positive signal,
absence of `HardwareAcceleratedCodecs` means nothing either way, only `="0"` is conclusive, and the
log settles it.
Also on the page:
- **GID `992` is no longer presented as a number to expect.** Under the plugin the injected device
arrives `root:root`, so the image's `cont-init.d` hook resolves GID **`0`** and adds `plex` to the
**root group** — measured `Groups: 0 100 1000`, `id plex` → `groups=1000(plex),0(root),100(users)`.
992 was the hostPath-era host `render` GID. The page now says so, marks the root-group membership as
an inherent property of the plugin's `DeviceSpec` rather than a defect, and repeats that it is
**not** a reason to reintroduce `supplementalGroups` (still a no-op: `s6-setuidgid` rebuilds the
group list from `/etc/group`). The stale `Groups: 100 1000` claim in the escalation section is gone.
- **A new troubleshooting entry for legitimate per-title fallbacks**, marked as a **hypothesis**.
Between two fully hardware sessions, one logged `enabled, but no hardware decode accelerator found`
and empty final decoder/encoder. UHD 630 decodes H.264, HEVC 8/10-bit and VP9 but **not AV1** and
not 10-bit 4:2:2 HEVC, so the likely cause is the source file, not the platform — explicitly
written as a lead to check first, **not** a confirmed diagnosis of that item.
- The status is stated at the top of the page and in a rewritten provenance footer that separates the
2026-10-02 confirmations from the superseded 2026-09-30/2026-10-01 hostPath-era readings.
- `runbooks/README.md`'s index entry for the page was stale in the same two ways (it still described a
`/dev/dri` hostPath and the dashboard-only proof) and is corrected to match.
Unchanged on purpose: the "STOP HERE IF PLEX IS `Pending`" section and the escape hatch (delete the
`hardware:` block from `catalog/plex.yaml`, re-render) keep exactly their existing prominence — they
are correct and they are the 2am material. The "no alert is possible" reasoning is also kept verbatim
in substance: Plex declares no `metricsEndpoint`, renders no ServiceMonitor, and a fallback alert
would be a rule evaluating against nothing.
**Documentation only.** No change to `catalog/`, `schemas/`, `apps/` or `platform/`.
## What I verified
- **Gate, in the pinned toolbox from this worktree** (`DEV_KUBECONFIG=/dev/null`, `ENGINE` shim
bind-mounting the main checkout's `.git`): **rc 3 UNDETERMINED**, diff base
`origin/main @ 948875aad37d`, with exactly the three expected skips and **no failures**. Green:
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy`. Skipped:
`spec-immutability` (spec repo unavailable → R-TEST-06 not verified), `gitea-settings`
(`SETTINGS_API_TOKEN` unset), `conformance` (`CLUSTER_AVAILABLE!=true` → L2/L3 not run).
Touched requirements: **R-OBS-04**, reported `UNVERIFIED` for lack of a cluster. `approval-policy`
passed as `N/A` locally (`event='none'`, not a `pull_request`) — it evaluates for real on the PR.
**rc 3 is not green**, it is the calibrated worktree result, and it is reported here as rc 3.
- **The gate was run three times, with the same rc 3 / three-skip result on each committed tip** —
including the final one. Worth recording from the first attempt: run *before* committing, with
`HEAD == origin/main`, the touched-requirement set is `NOT DETERMINABLE (on-base)` and the
R-TEST-03 coverage gate cannot be applied at all. Commit before gating from a fresh worktree branch.
- **`runbooks/kustomization.yaml` still carries `- plex-quicksync=plex-quicksync.md`** in the
`configMapGenerator` `files:` list (line 110) — checked explicitly, because without it the page
merges and still 404s. `README=README.md` is likewise present, which is what serves the corrected
index entry.
- **This change touches no `sensitive_paths`.** `infra/gitea/approval-policy.json` lists `ci/**`,
`.gitea/**`, `conformance/**`, `clusters/**`, `bootstrap/**`, `infra/gitea/**`,
`platform/kyverno/**`, `platform-config/kustomization.yaml`, `.sops.yaml`, `**/*.sops.yaml`,
`**/rbac.yaml`, `CLAUDE.md`. `runbooks/**` and `decisions/**` are not among them, and the
`approval-policy` stage passed.
- **No YAML was added or edited**, so `indent-sequences: consistent` (which blocked a sibling branch
today) is not in play here; `lint` passed over the full tracked YAML set regardless.
- **`DECISIONS-LOG.md` is untouched** — this handoff is its own file, per CLAUDE.md §7.
- **The PR's own CI run was read, not assumed** (PR #39, run 208, task 261, head `1677dff5688b`).
**It is RED, and not because of this branch.** Every stage this change could affect is green —
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy` (`approval-policy` evaluated
for real on the `pull_request` event and passed: no sensitive path touched). The single failure is
**`gitea-settings`**, quoting the measured field:
[fail] required_approvals: DECLARED 1 but LIVE is 0
[fail] Gitea has DRIFTED from infra/gitea/expected-branch-protection.json.
[fail] MERGE GATE: BLOCKED
Every other declared setting matched (`block_admin_merge_override`, `block_on_rejected_reviews`,
`dismiss_stale_approvals`, `enable_status_check`, `enable_push=false`,
`status_check_contexts = ["gate / gate (pull_request)"]`, `runners online: 1`). So **live `main`
currently requires ZERO approvals**, the declaration still says 1, and with
`REQUIRE_GITEA_SETTINGS: "true"` in `.gitea/workflows/gate.yml` that drift **blocks every PR in
the repo**, not just this one. Note the shape of it: the check that would notice approvals are
unenforced is the check that is failing. Local branch `fix/admin-approval-override` (`616a497`,
"fix(ci): let an administrator merge their own green PR") looks like the in-flight work that means
to change the declaration. **I did not touch it** — `infra/gitea/**` is a sensitive path, needs a
non-author approval, and is outside this branch's documentation-only scope. It is an operator
decision: re-assert the protection, or change the declaration deliberately in its own PR.
- The repository-wide grep for `992` to find what else still asserts it: remaining occurrences are in
`catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md`,
`platform/intel-gpu-plugin/README.md`, `platform/intel-gpu-plugin/kustomization.yaml`, and three
older `decisions/` entries.
## What I did NOT verify
- **I did not re-measure anything on the live cluster in this session.** Every platform number on the
page — the VAAPI/zero-copy log lines, `RW-OPEN-OK` as uid 1000, the `gpu.intel.com/i915` limits and
requests on the pod spec, `Groups: 0 100 1000`, `id plex`, the 27 `Preferences.xml` attributes, the
empty `/proc/*/fd` sweep — came to me as measurements taken earlier on 2026-10-02 and was
transcribed, not re-run. Per CLAUDE.md §2b, treat the page as reporting those readings rather than
as proof of current state; re-run the primary check before relying on it.
- **The per-title fallback cause is unverified and labelled as such.** The source codec of the title
that logged `no hardware decode accelerator found` was never read. The AV1 / 10-bit-4:2:2-HEVC
explanation is inferred from UHD 630's capability set alone.
- **Whether Plex's dashboard *ever* shows `Transcode (hw)` on this deployment is unknown.** It was
observed silent during working hardware transcoding; whether that is a client/version quirk, a
reporting bug, or something about this setup was not investigated. The page therefore demotes it
rather than explaining it.
- **The commands as written in the page were not executed from the page.** They are the forms that
were run, re-wrapped for `dev.sh`; in particular the `exec -it deploy/plex -- sh` route for the argv
check is noted as possibly lacking a TTY under `dev.sh run` and was not exercised in that shape.
- **Stale `992` copies outside `runbooks/` were left alone**, deliberately — scope was documentation
only, and `catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md` and
`platform/intel-gpu-plugin/` all still carry the hostPath-era `Groups: 44 100 992 1000` reading as
if current. `render.py --selftest` asserts around that comment, so correcting it is a real change
with gate consequences, not a doc edit. **That is open work.** Older `decisions/` entries also cite
992; those are historical records and should stay as written.
- **CI on the PR.** The branch is pushed and the PR opened; I did not watch the Gitea run report, so
nothing here should be read as "CI green". The only gate result I can quote is the local rc 3 above.
@@ -0,0 +1,76 @@
---
date: 2026-10-03
branch: fix/cilium-cgroup-hostroot
title: Cilium socket-LB never covered host processes — cgroup.hostRoot pointed at an unmounted path
---
## What changed
`cgroup.hostRoot` changes from `/run/cilium/cgroupv2` to `/sys/fs/cgroup` in all three parity
copies: `platform/cilium/helmrelease.yaml` (authoritative), `platform/cilium/values.yaml`
(mirror) and `infra/ansible/roles/cilium/templates/cilium-values.yaml.j2` (bootstrap).
`autoMount` stays `false`.
**The defect.** With `autoMount: false`, the Cilium chart only bind-mounts whatever exists at
`hostRoot`, and expects the operator to have mounted cgroup2 there (cilium 1.20.1 values.yaml).
The repo's rationale was "k3s provides the cgroupv2 mount". That is true only for
`/sys/fs/cgroup`. Nothing mounts cgroup2 at `/run/cilium/cgroupv2` on these nodes; it is an
empty `DirectoryOrCreate` hostPath. So the agent mounted cgroup2 there itself, from inside its
own private cgroup namespace, and that mount's root is the agent container's own leaf cgroup.
The socket-LB programs (`cil_sock4_connect` etc.) were attached to it and covered only the
agent.
**The consequence.** Every other host-netns process got no Service/LB translation:
- Service ClusterIPs were unreachable from every node's host.
- The node holding the Gateway's L2 lease could not reach the Gateway VIP (`no route to host`).
Other nodes reached it only because their packets crossed the wire to the lease holder, where
the tc datapath handles them.
- k3s-prod-1 holds both the Gateway lease and the kube-vip API VIP. Its apiserver could not reach
the OIDC issuer, logging `oidc: authenticator not initialized` since at least 2026-09-12
(899 occurrences by 2026-10-03). kubectl SSO through the VIP therefore failed, and in-cluster
OIDC clients failed on whichever calls landed on that apiserver.
- Webhooks and aggregated APIs were unaffected only because k3s runs
`--enable-aggregator-routing=true` (endpoint IPs, not ClusterIPs).
Cilium's own source (v1.20.1 `bpf/bpf_sock.c`, `__sock4_xlate_fwd`) handles exactly this case
when the hook does run: a host-netns connect to an L7-LB Service (the Gateway) is rewritten to
`127.0.0.1:<l7_lb_proxy_port>`, the local Envoy listener. The fix is to make the hook cover the
host.
## What I verified
- `cilium-dbg status --verbose` on k3s-prod-1: `Socket LB: Enabled`, `Coverage: Hostns-only`.
The live cilium-config shows `bpf-lb-sock=false`, but socket LB is implied by kube-proxy
replacement. The Gateway Service frontends `192.168.2.20:{80,443}` and
`10.43.225.79:{80,443}` map to `127.0.0.1:14337`.
- Host: `findmnt /run/cilium/cgroupv2` is empty on all three nodes, and `/sys/fs/cgroup` is
`cgroup2`.
- In the agent: `/run/cilium/cgroupv2` is a `cgroup2 none` mount, the agent's own cgroup reads
`0::/`, the mount has 0 child cgroups, and `bpftool cgroup show /run/cilium/cgroupv2` lists all
`cil_sock*` programs. k3s runs in `0::/system.slice/k3s.service`, and the agent container in
`/kubepods.slice/…/cri-containerd-3ad04cde….scope`.
- **The decisive test**, run on k3s-prod-1 in the same netns: raw TCP connect to
`192.168.2.20:443`, `10.43.225.79:443` and `10.43.59.141:8080` CONNECTED from inside the agent
container (covered cgroup) and FAILED from the host (`No route to host` / timeout).
- Blast radius: host-network pods are cilium, cilium-envoy, cilium-operator (API via
`192.168.2.9:6443`, not a Service), kube-vip (`127.0.0.1:6443`, `svc_enable=false`) and
node-exporter (no outbound). Nothing depends on host Service translation being absent; the
change only makes Service/LB addresses reachable from the host where they currently fail.
- `helm template cilium/cilium --version 1.20.1` with the new values, in the toolbox: the
DaemonSet bind-mounts hostPath `/sys/fs/cgroup` into `cilium-agent` and `clean-cilium-state`,
the cilium-config shows `cgroup-root: "/sys/fs/cgroup"`, and the init containers are unchanged
(no `mount-cgroup`).
- Toolbox gate: see the PR description.
## What I did NOT verify
- **The rollout itself.** After merge, the agent DaemonSet rolls one node at a time
(`maxUnavailable: 1`). On each node, afterwards: host TCP connects to the three targets above
must succeed. On k3s-prod-1, the apiserver's background OIDC init retry must stop logging
`authenticator not initialized` with no k3s restart.
- That the old programs, attached to the previous agent container's cgroup, disappear with it.
Expected, because cgroup BPF attachments die with the cgroup, but not observed.
- An end-to-end kubectl OIDC login (needs a browser).
- No regression test was added. A natural one is an L2 conformance check that a host-netns
connect to the Gateway VIP succeeds on the lease holder, but `conformance/**` is a sensitive
path. Left as a follow-up.
@@ -0,0 +1,45 @@
---
date: 2026-10-03
branch: fix/node-keycloak-hosts-pin
title: Pin the OIDC issuer to the Gateway VIP on prod nodes — the WAN answer does not hairpin
---
## What changed
Follow-up to PR #40 (`decisions/2026-10-03-public-dns-wan-target.md`). Public DNS for
`*.home.keithjackson.dev` now CNAMEs to the WAN IP. The prod nodes resolve through public DNS,
and the router does not hairpin, so the kube-apiserver could no longer reach its OIDC issuer
`keycloak.home.keithjackson.dev`.
- `roles/node_baseline`: a new `node_baseline_hosts_pins` list (default `[]`) and one
`blockinfile` task (tags `dns`, `hosts`). It writes the pins into `/etc/hosts` as a single
marked block. An empty list removes the block.
- `group_vars/k3s_prod/vars.yml`: pins `keycloak.{{ k3s_base_domain }}` → `192.168.2.20`, which
must stay equal to `GATEWAY_VIP` (parity comment added). It also replaces the stale "no
/etc/hosts pin is needed" paragraph with the measured reason the pin is now required.
Dev is unaffected (empty default).
## What I verified
- After PR #40 reconciled (`platform` lastAppliedRevision `576e1ab`, Healthy=True), on
k3s-prod-2 and k3s-prod-3: `getent hosts keycloak.home.keithjackson.dev` →
`136.51.115.252 wan.home.keithjackson.dev`. `curl https://keycloak…/realms/homelab/.well-known/openid-configuration`
→ exit 60, `ssl_verify_result=18` (the router's self-signed cert, not the Gateway).
- `journalctl -u k3s` (last 20 min) showed no OIDC/JWKS errors yet. The apiserver still holds
the keys it cached before the flip, so the failure is latent until a k3s restart or key rotation.
- Neither node has cloud-init (`/etc/cloud` absent), so nothing rewrites `/etc/hosts` at boot.
- `--check --diff --tags hosts --limit k3s-prod-2,k3s-prod-3`: each node gets exactly the
three-line marked block `192.168.2.20 keycloak.home.keithjackson.dev`.
- Outside-in, PR #40 works: check-host.net probes from 3 external nodes got `200 OK` from
`https://plex.home.keithjackson.dev/identity` at `136.51.115.252`.
## What I did NOT verify
- **k3s-prod-1**: Ansible cannot reach it. SSH host key verification fails (`known_hosts:94`,
ED25519 `SHA256:zgUS8OeWznzCX9hlN7XdZ8iLNkU1G2loXjtJFfEUYpE`), which is consistent with
the 2026-09-11 SSD replacement and reinstall. The operator must confirm the fingerprint
before the pin can be applied there.
- The playbook has not been applied for real. It is an operator run after merge:
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`.
- An end-to-end kubectl OIDC login after the pin (needs a browser).
@@ -0,0 +1,129 @@
---
date: 2026-10-03
branch: feat/plex-remote-access-l4
title: Plex remote access — raw tcp/32400 via a pinned LoadBalancer VIP, not the L7 Gateway
---
## What changed
Reported symptom: users cannot reach Plex from outside the LAN. **Two independent causes**, measured
rather than inferred:
1. **Plex advertised no remote connection at all.** Live `Preferences.xml` on prod:
`customConnections` **ABSENT**, `ManualPortMappingMode` **ABSENT**, while
`PublishServerOnPlexOnlineKey="1"` — publishing was on and there was nothing reachable to publish.
Plex's automatic UPnP/NAT-PMP path cannot work from a pod on the Cilium overlay.
2. **Nothing from the WAN reached the cluster.** `136.51.115.252:32400` refused;
`136.51.115.252:443` answered with a **self-signed router cert** (`C=ZZ, CN=%D`), not the
Gateway's LE wildcard. The Plex Service was `ClusterIP` only — no off-cluster path to 32400 by
any route.
This branch fixes the in-cluster half. **Remote access does not work until the operator also does
two manual steps** (`runbooks/plex-remote-access.md`): the WAN tcp/32400 port-forward, and ticking
"manually specify public port" in the Plex UI.
- **`apps/wave-0/plex-remote.yaml` (new, hand-written).** A `type=LoadBalancer` Service on tcp/32400
pinned to `${PLEX_L4_VIP}` via `lbipam.cilium.io/ips`, plus a `CiliumNetworkPolicy`
(`plex-allow-remote-32400`) admitting `world`/`cluster`/`host`/`remote-node` to 32400. Non-catalog
because `schemas/servicedefinition.schema.json` is `additionalProperties: false` and has no field
for a LoadBalancer Service or a pinned VIP — the same reason `apps/wave-0/pihole-dns.yaml`,
`platform/crafty/service-l4.yaml` and `apps/vms/unifi/50-service-l4.yaml` are hand-written.
- **`catalog/render.py`:** an `EXTRA_UNITS` entry (`plex-remote`, anchored to `plex`, `above: False`)
so `--emit-waves` places the unit in plex's own wave.
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_L4_VIP` (dev default `192.168.1.57`), plus the
substitution inventory comment updated to name `plex-remote: PLEX_L4_VIP`.
- **`clusters/prod/kustomization.yaml`:** prod override `PLEX_L4_VIP: 192.168.2.24`.
- **`apps/wave-0/kustomization.yaml`:** `plex-remote.yaml` added to `resources`.
- **`runbooks/plex-remote-access.md` (new):** the two manual steps, the four-step verification, why
no alert is possible, and the two documented warts below.
**`ADVERTISE_IP` was deliberately NOT set.** Setting it to `https://plex.home.keithjackson.dev:443`
was the alternative — it needs no new WAN hole and the mechanism was verified working on an
already-claimed server. **The operator chose the native path (2026-10-03)** so Plex terminates its own
TLS (`*.plex.direct`) and streams bypass the Envoy L7 hop. `catalog/plex.yaml`'s deferral note for
`ADVERTISE_IP` remains accurate and untouched.
**`externalTrafficPolicy: Cluster`, with a known cost.** `Local` would preserve the client IP, but
Cilium's L2 announcer does not follow the backend pod — the wart that cost this LAN an 8-day
Minecraft outage (see `platform/crafty/service-l4.yaml`). Consequence accepted and documented: SNAT
puts the client in `192.168.0.0/16`, inside `ALLOWED_NETWORKS`, so **Plex classifies remote streams
as LAN**. That is a metering effect (remote bandwidth limits will not bind), not an authorization one.
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK
it.** It needs the operator's `/approve-override <reason>` posted as a PR **review** of type
"Comment". I have deliberately not posted it: CLAUDE.md §1 forbids an agent clearing this on its own
PR, and the agent account holds `owner`, so the prohibition is a rule, not a limit.
## What I verified
All measurements against live prod, 2026-10-03, read-only.
- **Plex's advertised state**, the finding this branch rests on: `customConnections=<ABSENT>`,
`ManualPortMappingMode=<ABSENT>`, `ManualPortMappingPort=<ABSENT>`,
`PublishServerOnPlexOnlineKey="1"`, `allowedNetworks="10.0.0.0/8,192.168.0.0/16"`. Read by
attribute grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`).
- **The L7 Gateway path works and is NOT the problem.** Via the Gateway VIP,
`GET https://plex.home.keithjackson.dev/identity` → `HTTP 200`, `claimed="1"`, real
`machineIdentifier`. HTTPRoute `plex-32400`: `Accepted=True`, `ResolvedRefs=True`.
- **Plex enforces its own auth**, which is what makes an unfiltered WAN port defensible:
`/library/sections`, `/status/sessions`, `/:/prefs` all → **401** unauthenticated through the
Gateway.
- **The SNAT/allowedNetworks wart is real, not theoretical.** A tagged probe through the Gateway was
logged by Plex as `[10.0.2.214:36040 (Allowed Network (WAN))]` — the Envoy pod IP, classified as an
allowed network.
- **WAN state:** `136.51.115.252:32400` → connection refused. `136.51.115.252:443` → self-signed
cert `C=ZZ, ST=Somewhere, O=%D342e7e6b, CN=%D`. Public DNS resolves correctly:
`plex.home.keithjackson.dev` → CNAME `wan.home.keithjackson.dev` → `136.51.115.252`.
- **`192.168.2.24` is free in-cluster.** `CiliumLoadBalancerIPPool default-pool`: IPsTotal 11 /
IPsUsed 4 / IPsAvailable 7; the only allocations are `.20` (cilium-gateway-shared), `.21`
(unifi-l4-*), `.22` (crafty-l4-*), `.23` (pihole-dns-*). Not a node address (`.10/.11/.12`) and not
the kube-vip control-plane VIP (`.9`).
- **The manual-port-mapping step genuinely cannot be automated.** Grep of the running image's whole
init surface (`/etc/cont-init.d`, `/plex-common.sh`, `/usr/local/bin`) for `ManualPortMapping` and
`PLEX_PREFERENCE` → **NONE**.
- **`ADVERTISE_IP` would have worked on a claimed server** (recorded because the runbook's
"why not" rests on it): `setPref "customConnections"` at line 126 of `40-plex-first-run` sits
**outside** the `PLEX_CLAIM`/`PlexOnlineToken` guard, and the `/.firstRunComplete` early-exit
marker is on the **overlay** FS, not the `/config` PVC, so the script re-runs every pod start.
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
**pass**. The rc 3 is two environmental skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`)
and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3, not as pass.**
- **`render-golden` passing is the specific proof** that the `render.py` `EXTRA_UNITS` change, the new
hand-written unit and the edited wave kustomization are mutually consistent.
## What I did NOT verify
- **THE ACTUAL FIX. Nothing in this branch has been proven to make remote access work** — it cannot
be, because both remaining steps are off-repo and manual (router forward, Plex UI toggle). This
branch removes two blockers and adds none; it does not demonstrate an outcome.
- **Outside-in reachability, at all.** Every probe above was taken from inside the LAN, so the
`:443` self-signed cert strictly proves only that there is **no NAT hairpin** — it does **not**
prove outside-in failure. A router cert on WAN:443 is suggestive (its management UI would shadow a
forward), but unproven. **This repo has no off-LAN vantage point.** Test 3 in the runbook
(app.plex.tv from a cellular connection) is the only honest check.
- **`192.168.2.24` was never ARP-probed off-cluster.** Only in-cluster pool accounting was checked.
`.23` was `arping`-probed before it was claimed; `.24` was not, so an off-cluster squatter would
not have been caught.
- **The LoadBalancer has never been reconciled.** `plex-l4-tcp` does not exist in prod yet, so
LB-IPAM has not actually assigned the pinned VIP, L2 announcement has not been observed for it, and
the new CiliumNetworkPolicy has never admitted a real packet. Whether `world`/`host`/`cluster`/
`remote-node` is the correct entity set **under this SNAT path** is reasoned from
`platform/crafty/networkpolicy.yaml`'s working game-port rule, **not measured**.
- **Whether Plex's remote path needs anything beyond the manual port tick.** If plex.tv still lists
no remote connection after both manual steps, this branch is necessary-but-insufficient.
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not
a verdict: this PR touches `clusters/**`, so CI will block it pending an override.
- **PRE-EXISTING DRIFT I FOUND AND DELIBERATELY LEFT ALONE — worth its own follow-up.** Running the
documented `python3 catalog/render.py --emit-waves` regenerates the wave kustomizations and wants to
add **`vm-demo.yaml`** to wave 0 and **`keycloak.yaml`** to wave 1. Both are listed as
intentionally not onboarded in `ci/render-golden.sh`
(`RENDER_GOLDEN_NOT_ONBOARDED="keycloak vm-demo"`); their manifests are tracked in `apps/` but
deliberately absent from the `resources` lists so Flux does not apply them. `--emit-waves` places
any file present on disk and has **no awareness of that exclusion**, while `render-golden.sh` uses
`--wave-layout` and compares rendered manifests only — it never checks kustomization `resources`,
which is why this has survived unnoticed. **So the header "DO NOT hand-edit: re-run
`render.py --emit-waves`" is currently unsafe advice**: following it onboards an IdP and a KubeVirt
VM. I therefore restored both kustomizations and hand-added only my one line. I did not fix
`--emit-waves`, and I have not determined how long the drift has existed or whether either service
is *meant* to be onboarded.
@@ -0,0 +1,73 @@
---
date: 2026-10-03
branch: feat/public-dns-wan-target
title: Public DNS now points at the WAN — Gateway-published names CNAME to an in-cluster DDNS record
---
## What changed
The operator closed the open "public-target gap" recorded in
`platform/externaldns/helmrelease.yaml`. The WAN NAT / port-forwarding (80/443 → Gateway VIP
192.168.2.20) was done by the operator outside this repo.
Operator decisions (2026-10-03):
1. **Scope: every Gateway-published name goes public.** That covers the five `exposure: public`
apps (plex, gitea, keycloak, nextcloud, seerr) and also the hand-authored admin UIs (auth,
crafty, grafana, headlamp, the `home.keithjackson.dev` apex). `internal` services still publish
nothing: their `controller: none` route annotation is checked before the target is read.
2. **The WAN IP is kept current in-cluster**, not hardcoded.
Implementation:
- `platform/gateway/gateway.yaml`: `external-dns.alpha.kubernetes.io/target: wan.${BASE_DOMAIN}` on
the shared Gateway. Every gateway-sourced record changes from `A 192.168.2.20` to
`CNAME wan.home.keithjackson.dev`.
- `platform/externaldns/ddns.yaml` (new): `favonia/cloudflare-ddns:1.17.1` Deployment in the
`external-dns` namespace. It reuses `cloudflare-api-token` and keeps `wan.${BASE_DOMAIN}` an
unproxied IPv4 A record at the WAN IP (detected via Cloudflare trace, checked every 5 min,
`DELETE_ON_STOP=false`). external-dns has no TXT registry entry for it, so it never owns or
deletes it.
- The helmrelease comment block and the README now describe the decision instead of the gap.
Known consequence: **k3s nodes resolve through public DNS** (`node_baseline_dns_servers` = 8.8.8.8 /
1.1.1.1). The kube-apiserver's OIDC issuer `keycloak.home.keithjackson.dev` therefore resolves to the
WAN IP from the nodes, and kubectl SSO works only if the router hairpins WAN:443 back to the Gateway.
A LAN-side probe of `136.51.115.252:443` on 2026-10-03 returned a **self-signed placeholder cert**,
not the Gateway's Let's Encrypt wildcard. That suggests no hairpin. The cert-based admin kubeconfig
is unaffected.
## What I verified
- Live prod, 2026-10-03, before the change: Gateway `gateway/shared` ADDRESS `192.168.2.20`.
External-dns args include `--source=gateway-httproute --source=service --policy=sync
--txt-owner-id=prod --domain-filter=keithjackson.dev`, and it logs "All records are already up to
date".
- Public answers at 1.1.1.1: plex/gitea/keycloak/grafana/auth/apex → `192.168.2.20`;
`crafty-mc` → `.22` and `unifi` → `.21` (service source, not affected by this change); jackett →
none (internal, correctly unpublished).
- The authority (betty.ns.cloudflare.com) has no unprefixed TXT, AAAA or MX at `plex.` or the apex,
so the A→CNAME switch has no co-existing record to collide with.
- WAN IP from the workstation: `136.51.115.252`.
- No CiliumNetworkPolicy or NetworkPolicy in `external-dns`. The Kyverno label and default-deny
policies match only `app-namespace` namespaces. The vpn egress policy selects only
`egress-class: vpn` pods.
- Toolbox gate result: see the PR description (run after this commit).
## What I did NOT verify
- **The outside-in path.** I have no vantage point outside the LAN. Whether
`https://<svc>.home.keithjackson.dev` from the internet reaches the Gateway with the LE cert is
UNVERIFIED.
- **Hairpin / kubectl SSO after the flip.** Likely broken per the probe above. Fix candidates: a
node `/etc/hosts` pin for `keycloak.${BASE_DOMAIN}` → Gateway VIP via `node_baseline`, or router
NAT reflection.
- **The record-type flip itself**, until Flux reconciles: whether Cloudflare accepts external-dns's
create-CNAME in the same batch as the delete-A, or converges one interval later.
- `favonia/cloudflare-ddns` under this pod securityContext. The README warns that
`no-new-privileges` misbehaves on some Proxmox-style hosts. These are bare-metal nodes, but it is
untested until the pod runs.
- Whether the L4 service-source names (crafty-mc, unifi) should also go public. Not decided, not
changed.
- `conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh` header lines 39–40
still describe the target as open. That is a sensitive path, so it is left for a follow-up.
@@ -0,0 +1,157 @@
---
date: 2026-10-04
branch: feat/hubble-flow-export-plex
title: Record the pre-SNAT client address for Plex via Hubble static flow export, instead of breaking externalTrafficPolicy to get it
---
## What changed
Operator requirement, narrowed explicitly: **this is 100% about log visibility** — knowing which
address connected to Plex. Not bandwidth metering, which does not matter here.
**The problem, measured.** Plex's own log cannot show it. Every LoadBalancer Service in this cluster
is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the VIP SNATs the client
before forwarding to the backend pod:
| Measured 2026-10-04 | Value |
|---|---|
| Node announcing `192.168.2.24` | `k3s-prod-3` (also holds `.20` Gateway, `.23` pihole-dns) |
| Node running the Plex pod | `k3s-prod-1` (`10.0.0.135`) |
| Sources in Plex's log | **3,214 × `10.0.1.x`**, 105 × `10.0.2.x`, 120 × loopback |
| `192.168.x` sources in Plex's log, ever | **0** |
| `X-Forwarded-For` in Plex's log | **0** (it serves raw tcp/32400, not HTTP via the Gateway) |
| `10.0.1.78` identity (`cilium-dbg bpf ipcache`) | `identity=1` → `reserved:host` = prod-3 itself |
**Nothing on the router is involved** — the operator correctly pointed out there is no hairpin NAT
here. The rewrite is entirely in-cluster, done by Cilium's BPF load-balancer on the lease holder,
because the backend pod is on a different node.
**The address is not lost, only unrecorded.** Cilium observes the flow at ingress, *before* its own
translation, so Hubble already holds the true source:
192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
10.0.1.78 -> 10.0.0.135 (the same traffic post-SNAT — what Plex sees)
What was missing is **retention**: the ring buffer was **4095/4095 flows at ~303 flows/s — about
thirteen seconds of history** — with hubble metrics disabled and no exporter configured.
So: enable the static flow exporter, scoped to Plex's port, writing to the agent's stdout.
- **`platform/cilium/helmrelease.yaml`** (authoritative): `hubble.export.static.enabled: true`,
`filePath: stdout`, `allowList` of `{"destination_port":["32400"]}` and
`{"source_port":["32400"]}`.
- **`platform/cilium/values.yaml`** (reference mirror) and
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
the same block, so a bootstrap render does not differ from what Flux reconciles — a mismatch would
be a permanent Helm diff and an avoidable agent rollout.
**`filePath: stdout`, not a file,** because Grafana Alloy already runs as a DaemonSet tailing every
pod's logs into Loki (`platform/monitoring/alloy.yaml`). stdout therefore reaches Loki with **no new
hostPath mount, no file-rotation handling, and no Alloy change at all**. Upstream documents the
value: *"To get Hubble flows directly exported to the logs instead of written to a rotated file,
`stdout` can be defined as `hubble-export-file-path`."* Query:
{namespace="kube-system", app="cilium"} |= "32400"
**SCOPED BY PORT, NOT BY NAMESPACE — a measured decision, not a preference.** A
`--to-namespace plex` query returned **only the post-SNAT flows**; the flow carrying the real client
address did not match it, because at the point of observation the destination identity is not yet
resolved to the plex pod. A namespace filter would have dropped exactly the record this change
exists to capture.
**WHY NOT FIX THE SNAT INSTEAD — both alternatives rejected on evidence.**
- `externalTrafficPolicy: Local` would put the real address in Plex's own log, but cilium 1.20's L2
announcer is **explicitly incompatible** with it. Upstream docs for this exact version:
*"incompatible with the `externalTrafficPolicy: Local` on services as it may cause service IPs to
be announced on nodes without pods causing traffic drops"* and *"L2 announcements isn't currently
aware of this setting"*. Live proof it is not theoretical: the VIP is announced by prod-3 while the
pod runs on prod-1, so the flip would black-hole tcp/32400 **immediately**. Making it safe means
pinning both the pod and the announcement to one node and surrendering Plex's ability to
reschedule — a real availability loss to buy a log field. Same trap
`platform/crafty/service-l4.yaml` documents from an 8-day Minecraft outage.
- **DSR** also preserves the client address, but `routing-mode` here is **vxlan**, not
native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO router's stateful
NAT on the remote path. Cluster-wide blast radius across all eight LB Services for one service's
logging.
**A correction to the repo's own record.** `decisions/2026-10-03-plex-remote-access-l4.md` states the
SNAT puts the client inside `192.168.0.0/16` (the ingress node's LAN address), and I repeated that in
the previous entry. **It is wrong**: measured, the SNAT source is `10.0.1.x` — the node's
`cilium_host` inside the pod CIDR. This matters, because it means `LanNetworksBandwidth` can never
discriminate local from remote while eTP is `Cluster`: `10.0.0.0/8` must be in the LAN set for the
in-cluster consumers (Seerr) to count as local, and every external client arrives in that same range.
PR #48's LAN definition therefore fixes the Remote badge by making *everything* local — acceptable
only because metering is explicitly not a goal here.
## What I verified
Live prod, 2026-10-04, read-only.
- **Every measurement in the table above**, including the VIP-lease-to-node map
(`cilium-dbg shell -- db/show l2-announce` on all three agents) and the pod's node.
- **Hubble already carries the pre-SNAT source** — the single finding this whole approach rests on:
`hubble observe --port 32400` on the lease holder returned `192.168.2.133 -> 10.0.0.135` **and**
its return flow, alongside the post-SNAT `10.0.1.78 -> 10.0.0.135`.
- **The retention gap is real:** `cilium-dbg status` reported `Hubble: Ok Current/Max Flows:
4095/4095 (100.00%), Flows/s: 303.62 Metrics: Disabled`, and the `cilium-config` ConfigMap
contains no `hubble-export-*` key at all.
- **The namespace-filter trap**, directly: `--to-namespace plex` returned only `10.0.1.78` and
`10.0.1.71`; the `192.168.2.133` flow was absent. This is why the allowList is port-scoped.
- **All eight LoadBalancer Services are `eTP=Cluster`** (crafty ×2, gateway, pihole-dns ×2, plex,
unifi ×2) — so this is a cluster-wide pattern, not a Plex-specific choice.
- **The eTP:Local incompatibility is upstream-documented for v1.20 specifically**, quoted above from
the docs for the running version — not inferred from the crafty incident.
- **`routing-mode: tunnel`** and **`bpf-lb-mode-annotation: false`** in the live `cilium-config`,
which is what rules DSR out.
- **The chart's value paths and defaults**, read from cilium v1.20.1's own `values.yaml`:
`hubble.export.static.{enabled,filePath,fieldMask,fieldAggregate,aggregationInterval,allowList,denyList,fileMaxSizeMb,fileMaxBackups,fileCompress}`,
with `filePath` defaulting to `/var/run/cilium/hubble/events.log`. The keys used here exist.
- **Alloy's pipeline shape**, read from `platform/monitoring/alloy.yaml`: a DaemonSet with
`loki.source.kubernetes "pods"` → `loki.process` → `loki.write` at
`http://loki.monitoring.svc.cluster.local:3100`. It already tails kube-system pods, which is why
stdout needs no Alloy change.
- **All three value files agree** (one `destination_port` occurrence each) and both YAML files parse,
with `export.static` resolving to the intended object.
- **Toolbox gate**: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
`render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two known environmental skips
(`gitea-settings` — no local `SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE`
unset). **Not green. Reported as rc 3.**
- **No sensitive paths touched** — `platform/cilium/**` and `infra/ansible/**` are not in
`infra/gitea/approval-policy.json`, so this needs no approval override, unlike PR #48.
## What I did NOT verify
- **THE OUTCOME. Nothing has been reconciled and no flow has ever been exported.** `cilium-config`
still has no `hubble-export-*` key. Every claim is about inputs.
- **Whether `filePath: stdout` is accepted as written.** This is the single largest risk. The value
comes from a verbatim upstream doc sentence (*"`stdout` can be defined as
`hubble-export-file-path`"*), but I did not confirm the exact notation in cilium 1.20.1's source,
and the chart default is a real filesystem path. If the literal string is not special-cased, the
agent may create a FILE named `stdout` in its working directory and nothing reaches Loki — a
silent no-op, not a crash. **Fallback if so:** leave `filePath` at the chart default and add a
`local.file_match` + `loki.source.file` target to Alloy with a hostPath mount for
`/var/run/cilium/hubble`.
- **Whether the FlowFilter JSON is accepted.** `{"destination_port":["32400"]}` follows the proto
(`destination_port` is a repeated string) and the documented shape (`'{"verdict":["DROPPED"]}'`),
but upstream gives **no port example** and I did not validate it against the running agent. A
malformed filter could be rejected — or, worse, be silently treated as "no match" and export
nothing.
- **THIS CHANGE ROLLS CILIUM.** Editing the HelmRelease restarts the agent DaemonSet across all
three nodes. BPF maps are preserved so established traffic should survive, but prod-3 currently
announces the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP, so a lease change during the
rollout could briefly interrupt LAN DNS for the whole house. Not measured, and worth doing at a
quiet moment.
- **Log volume in practice.** Estimated "a few hundred to a few thousand lines a day" from the
observed request counts; never measured as flows. Both directions are exported and no `fieldMask`
is set, so each record is a full flow object. If it is noisy, narrow with `fieldMask` rather than
by dropping a filter.
- **Loki's retention for this data**, and whether the `{namespace="kube-system", app="cilium"}` label
set actually matches (it depends on the cilium pods carrying
`app.kubernetes.io/name`, which Alloy maps to `app` — I did not confirm that label exists on the
cilium DaemonSet pods). The query in the comment may need adjusting to `pod=~"cilium-.*"`.
- **That the exported flow actually contains the pre-SNAT address.** Hubble's *live* stream does
(measured). I am assuming the exporter sees the same flow objects; I did not confirm the exporter
is not fed a post-translation view.
- **The parity convention has no automated check.** I kept all three files in sync by hand because
the comments demand it; nothing in `ci/` or `conformance/` verifies cilium Helm↔Ansible parity, so
a future edit can still drift them silently.
@@ -0,0 +1,155 @@
---
date: 2026-10-04
branch: fix/oauth2-sidecar-metrics-scrape-netpol
title: The forward-auth gate sidecars' /metrics was NetworkPolicy-blocked — the renderer now opens 44180 to ns monitoring
---
> **Added 2026-10-05, after merge:** the top item under *What I did NOT verify* below — "that the
> scrape now succeeds" — **has since been verified on prod**. All 6 targets read `up == 1`. The
> original text is left exactly as written; the measurements are in
> [[2026-10-05-oauth2-sidecar-scrape-verified]].
## What changed
`catalog/render.py`'s `r_monitoring_ingress_policy` now emits its CiliumNetworkPolicy for a
**second** reason: the forward-auth gate sidecar's own `/metrics` port (44180), keyed on
`_needs_oauth2_sidecar(s)`. Previously it fired only from `observability.metricsEndpoint`.
That matters more than it sounds, because **no ServiceDefinition in this catalog declares a
`metricsEndpoint`** (measured: zero grep hits across `catalog/*.yaml`). So the function was wired
in at the call site and had **never emitted a single object on any branch** — correct, and
entirely latent. Its presence in the tree read as "scrape ingress is handled". It was not.
Re-rendered `apps/` from that change: five new CNPs, one per forward-auth app
(`jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`), each opening **only** 44180 to **only**
`fromEndpoints: [k8s:io.kubernetes.pod.namespace: monitoring]`.
The bug being fixed: `platform/oauth2-proxy/podmonitor.yaml` landed 2026-10-02T04:58:44Z and
discovered 6 targets that have **never once scraped successfully**. Each app namespace ships a
`default-deny` NetworkPolicy with `policyTypes: [Ingress, Egress]` and **no `ingress` key at all**
— deny-all-ingress in NetworkPolicy semantics — and the only ingress allowance is
`<app>-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180. Prometheus matched no
clause, so its packets were dropped.
Two deliberate choices, both recorded in the function's docstring:
- **Clause 2 keys on `_needs_oauth2_sidecar`, not on `observability.metrics`.** It has to match
the condition the *scrape* keys on: `podmonitor.yaml` selects pods by the
`homelab.keithjackson.dev/auth-mode: forward-auth` pod label and nothing else, so a target
exists iff the sidecar exists. Keying on `observability.metrics` would let `metrics: false`
produce a discovered-but-blocked target — the same "two halves authored apart" failure this
function exists to prevent, moved one field over.
- **The policy is rendered here, not added to `platform/oauth2-proxy/`.** podmonitor.yaml's own
header records why that tier cannot host it: it would name app namespaces created by the
*sibling* `apps-wave-0/1` tiers, so it would apply on a running cluster and fail on a cold
bootstrap. Rendered per-app it ships in the same tier that creates the namespace.
## What I verified
Measured on prod 2026-10-04, read-only, via the pinned toolbox:
- **The cause, not a guess.** All 6 sidecar targets in pool
`podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0`: `health=down`,
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly `10.0s` — the configured
`scrapeTimeout`. Hitting the timeout precisely means packets **dropped, not refused**.
- **The listener is fine.** jackett's `oauth2-proxy` container carries
`--metrics-address=0.0.0.0:44180` and a named port `metrics: 44180`.
- **The deny is universal.** All six namespaces (`jackett radarr sonarr qbittorrent pihole
crafty`): `default-deny` with `ingress` key absent, `nIngress=0`; the only ingress CNP allowance
is 4180 `fromEntities: [ingress]`. **Zero** namespaces had any `-allow-monitoring-scrape` object.
- **Frequency.** Single continuous episode, no flapping: `ALERTS_FOR_STATE` = 229343s (63.7h),
firing since 2026-10-02 05:09 UTC — 11 minutes after the PodMonitor was created, i.e. TargetDown's
`for: 10m`. A 15-day `query_range` shows **1 episode** per namespace, not many. Alertmanager:
`group_by: [alertname, namespace]`, `repeat_interval: 12h` on `email-warning` → 5 TargetDown
groups = 10 emails/day, plus 6 `OAuth2ProxyGateUnobserved` groups = 12/day, ~22/day total.
0 silences.
- **The re-render is deterministic and the diff is exactly the intended one.** `apps/` is
byte-identical to `render.py --all catalog -o apps --wave-layout --no-image-automation`
(12/12 SAME before my edit; the 11-file "drift" against a default render is solely the
ImageRepository/ImagePolicy pair, which `apps/` is generated without). After the edit:
5 files changed, +21 lines each, nothing else touched.
- `render.py --selftest`: **PASS**.
- **Three L1 goldens needed regenerating, and the diff is +63 lines / -0.** The first gate run
caught `container-workload-render` (R-PLAT-02) and `egress-class-render` (R-NET-05/06/07) as
FAIL with exactly the new CNP as the drift. Regenerated with the same invocations the checks
use (`render.py <fixture>`, no flags). Evidence the regen is faithful rather than a rubber
stamp: `default-deny-baseline-render`'s golden came back **byte-identical** (its fixture is not
forward-auth), and the other three gained only the one CNP, port 44180, ns monitoring.
- Toolbox gate `./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`: **rc 3 — UNDETERMINED,
NOT rc 0, and I am not reporting it as green.** Every stage that ran is green
(spec-immutability, approval-policy, decisions-log, lint, l0, render-golden, l1); `l1` went
FAIL -> pass once the goldens were regenerated. The gate's own words: "Stages that did not run:
gitea-settings" — skipped for want of `GITEA_SETTINGS_TOKEN`, which is an environment gap on
every local run and unrelated to this change. L2/L3 conformance is a `[warn]`, not a not-run
stage: UNVERIFIED for all 8 touched requirements (R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01
R-NET-05 R-NET-06 R-NET-07 R-PLAT-02), no cluster.
- `--emit-waves` was **not** run (it re-onboards `vm-demo` + `keycloak`); only the per-service
render path was used, so no wave kustomization changed.
## This PR needs a human approval (sensitive path) — MEASURED, not predicted
CI run #230 on PR #49, head `f9446be3`: **`MERGE GATE: BLOCKED`, and `approval-policy` is the ONLY
failing stage.** Read from the job log, not from the status tick:
[skip] stage spec-immutability: SKIPPED (not run) <- sibling spec repo absent in CI; expected
[ ok ] stage decisions-log: pass
[ ok ] stage lint: pass
[ ok ] stage l0: pass
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[fail] stage approval-policy: FAIL
[ ok ] stage gitea-settings: pass <- could NOT run locally; green in CI
[skip] stage conformance: SKIPPED (not run)
sensitive paths touched:
conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml
conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml
conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml
Exactly the three regenerated goldens, nothing else. `l1` and `render-golden` both pass in CI,
which is the independent confirmation that the regenerated goldens are right rather than merely
self-consistent with my local run.
One local-reproduction note worth recording, because it wasted a step: running
`PR_NUMBER=49 SETTINGS_API_TOKEN=... ci/approval-policy.sh` with the credential from
`git credential fill` reports `could not determine PR author — BLOCKING`. That is an **artifact**,
not a finding — `~/.git-credentials` holds a *password*, and `Authorization: token <password>`
returns 401 `user does not exist [uid: 0]`. The stage needs a real API token. I nearly reported
that 401 as the CI failure; the job log is what settled it.
Regenerating the L1 goldens means this branch touches `conformance/**`, which is in
`sensitive_paths` in `infra/gitea/approval-policy.json` ("the tests and the harness; changing them
changes what green MEANS"). So `ci/approval-policy.sh` will require an approving review from a
non-author, and the local gate **cannot** see that: it reports `approval-policy: green` because
offline it evaluates N/A.
The golden update is not optional — a renderer change that adds an object necessarily drifts the
goldens that pin the renderer's output — but it does mean the gate correctly refuses to
self-approve this one. **An agent must not clear this with `/approve-override`**, even though the
`gitea-admin` account holds `owner`: that is precisely the self-approval the stage exists to
prevent. The operator posts the override as a PR *review* of type "Comment", or approves normally.
## What I did NOT verify
- **That the scrape now succeeds.** This is a git-only change and nothing is applied until Flux
reconciles `main`. The 5 CNPs have never existed on the cluster, so `up == 1` for those targets
is **UNVERIFIED** — it is a prediction from the measured drop signature, not an observation.
After merge, confirm `up{container="oauth2-proxy"}` goes to 1 for all 6 targets and that the 11
alerts resolve. `send_resolved: true` is set, so expect ~11 resolved emails.
- **That the CNP `endpointSelector` matches pihole's pods.** It selects
`app.kubernetes.io/name: pihole`, identical to the shape of `pihole-allow-gateway-ingress`
which demonstrably works for 4180 — so this is inference from a working sibling, not a
measurement of the new object.
- **`crafty` is still unobserved and will keep firing `OAuth2ProxyGateUnobserved`.** It is
hand-managed under `platform/crafty`, outside the renderer, and has **no `--metrics-address`
at all** — so there is no port to open and this change cannot help it. Separate work.
- **Whether 44180 is reachable in the Cilium data path once allowed.** Only the policy was
reasoned about; no L2 probe was run (CI has `vars.CLUSTER_AVAILABLE` unset, so L2/L3 did not
run here either — the gate proves the L0/L1 offline subset only).
- **Why `OAuth2ProxyGateUnobserved` started at 2026-10-04 06:44** rather than 10-02 with
TargetDown. Alertmanager did not restart (TargetDown's `startsAt` is still 10-02) and the rule's
`for` is 1h, so neither explains it. Something changed on the monitoring side that morning which
I did not identify. It does not affect this fix.
- I did not re-run a port-forward to 44180 to prove the endpoint serves *today*; I confirmed the
flag and named port on the live pod spec. The 200/10208-byte measurement is podmonitor.yaml's,
from 2026-10-01.
@@ -0,0 +1,133 @@
---
date: 2026-10-04
branch: feat/pihole-declare-state
title: PiHole's operator-set state is now declared in git — the prerequisite for running more than one replica
---
## What changed
Phase 1 of making LAN DNS survive a node failure. The operator asked for multi-replica PiHole;
measurement showed that cannot be done safely first, because PiHole's important state was not
reproducible from git. A second replica with a fresh PVC would have come up **serving DNS and
blocking nothing, with a different admin password** — answers varying by which pod replied. This
branch closes that; the replica work is phase 2 and is deliberately NOT here.
**The undeclared-state inventory, measured not guessed.** `pihole.toml` marks every non-default
setting `### CHANGED`, and those that came from the environment `### CHANGED (env)`. The complete
live list on 2026-10-04:
| Setting | Status |
|---|---|
| `dns.upstreams`, `dns.listeningMode`, `dns.rateLimit.count`, `misc.dnsmasq_lines` | `### CHANGED (env)` — already declared in `catalog/pihole.yaml` |
| `files.macvendor` | `### CHANGED` — set by the image itself (container path), not operator state |
| `webserver.api.pwhash` | **`### CHANGED` — set out of band. Declared NOWHERE.** |
So exactly **one** operator-set value lived only on the PVC. Separately, the **adlists** were also
undeclared: `gravity.db` held one list (StevenBlack's hosts) and 72,234 gravity domains, with nothing
in this repo naming it.
- **`apps/wave-0/pihole-bootstrap-secret.sops.yaml` (new, SOPS/age).** Secret `pihole-bootstrap`,
key `webui-password`. Named to the `<svc>-bootstrap-secret.sops.yaml` convention so
`render.py --emit-waves` places it with no renderer change. The value is a 32-char random password
generated at authoring time and **never printed to a transcript**.
- **`catalog/pihole.yaml`:** `FTLCONF_webserver_api_password` sourced from that Secret.
`webserver.api.password` takes CLEARTEXT and FTL hashes it itself — verified in the running image,
where `pihole setpassword` *is* `setFTLConfigValue "webserver.api.password"`, commented upstream
"pihole-FTL will automatically hash the password". The PVC's `pwhash` becomes a derived artefact.
- **`catalog/pihole.yaml`:** a `lifecycle.postStart` hook that **reconciles** the declared adlist set
into `gravity.db` on every container start — adds missing, re-enables disabled, and **disables**
(never deletes) anything enabled but undeclared, running `pihole -g` only when something moved.
Same level-triggered idiom, and the same reasoning, as `catalog/qbittorrent.yaml`'s postStart.
- **`apps/wave-0/pihole.yaml`, `apps/wave-0/kustomization.yaml`:** re-rendered / secret registered.
**Why a postStart hook and not a file.** PiHole v6 keeps adlists as DB rows. `/etc/pihole/adlists.list`
still exists in `gravity.sh` but is **migration-only**: its import sits inside `migrate_to_database()`
behind `if [ ! -e "$gravityDBfile" ]`, so it fires only while *creating* a database. A ConfigMap
mounted there would be silently ignored on every pod this cluster runs today. There is also no
`pihole adlist` CLI subcommand, so SQL is the actual interface; FTL ships an embedded shell
(`pihole-FTL sqlite3`) so nothing had to be added to the image.
**The password is kept rather than set empty, deliberately.** The admin UI is already behind the
oauth2-proxy sidecar (`--allowed-group=platform-admins`), so an empty password would have been
simpler. But the `pihole` Service still publishes **port 80** (PiHole direct, bypassing the proxy on
`:4180`) and only the CiliumNetworkPolicy closes it — and this repo has a documented default-deny
drift history. An unauthenticated admin UI that can rewrite LAN DNS should not rest on one policy
staying correct. Narrowing that Service port is follow-up, not done here.
**SENSITIVE PATHS — this PR adds a `**/*.sops.yaml`, so `ci/approval-policy.sh` will BLOCK it.** It
needs the operator's `/approve-override <reason>` as a PR **review** of type "Comment". Not posted by
me: CLAUDE.md section 1 forbids an agent clearing this on its own PR.
## What I verified
Live prod, 2026-10-04, read-only except where noted.
- **The undeclared-state inventory above** — read from `pihole.toml`'s own `### CHANGED` markers, with
the password value masked. This is the measurement the whole branch rests on.
- **A fresh `gravity.db` seeds NO adlists.** The image's own schema template
`/etc/.pihole/advanced/Templates/gravity.db.sql` creates the `adlist` table and contains no
`INSERT INTO adlist` at all — grepped in the running image. Confirmed empirically below (0 rows).
This is why the adlist declaration is load-bearing and not cosmetic.
- **The adlists.list path really is migration-only** — read the guard in `/opt/pihole/gravity.sh`
(`migrate_to_database()`, `if [ ! -e "$gravityDBfile" ]`).
- **`webserver.api.password` is a real settable key** — `pihole-FTL --config webserver.api.password`
returns a masked value, and `/usr/local/bin/pihole`'s `setpassword` sets exactly that key.
- **THE RENDERED HOOK ACTUALLY WORKS — all four paths, extracted from `apps/wave-0/pihole.yaml` and
run in the live pod against THROWAWAY COPIES of `gravity.db` in `/tmp`** (production `gravity.db`
was never written, and `pihole -g` was stubbed so no 70k-domain download could start):
1. *Steady state* → `already matches the declared set - nothing to do`. No writes, no rebuild.
2. *True fresh DB built from the image's schema template* → 0 adlists before; hook added the
declared list and triggered the rebuild. **This is the fresh-PVC/new-replica case.**
3. *An undeclared list enabled* → disabled it (`enabled=0`), left the declared one alone, and did
not delete the row.
4. *Declared list present but disabled* → re-enabled it.
- **Required tools exist in the image:** `bash`, `expr`, `grep`, `printf`, `sleep`, `pihole-FTL`.
- **The Secret is genuinely encrypted** to the repo's age recipient; `stringData.webui-password`
starts `ENC[`. The gate's `no-plaintext-secrets (R-SEC-01)` L0 check passes.
- **Schema:** `check-jsonschema` against `schemas/servicedefinition.schema.json` passes.
- **Render determinism:** the canonical
`render.py --all catalog -o apps --wave-layout --no-image-automation` changed **only**
`apps/wave-0/pihole.yaml`. `render-golden` passes, so the hook's text also satisfies
`assert_substitution_safe` (no braced dollar expansion — the Flux envsubst contract).
- **Toolbox gate:** rc **3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
`render-golden`, `l1`, `approval-policy` all **pass**. The rc 3 is the two usual environmental
skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3
(`CLUSTER_AVAILABLE` unset). **Not green; reported as rc 3.**
## What I did NOT verify
- **Nothing here has been reconciled.** Flux has not applied any of it, so the password has never
been set by this mechanism and the hook has never run during a real container start. Everything
above is the rendered artifact tested out-of-band, plus the image's own source.
- **THE CURRENT ADMIN PASSWORD WILL STOP WORKING, and that is the intent.** FTL re-hashes
`webserver.api.password` on start, overwriting the existing `pwhash`. Whoever knows the present
password (nobody, which is why this started) loses it; the declared one replaces it. I have not
observed that replacement happen.
- **Cold-start cost is unmeasured and could matter.** `postStart` must complete before the container
is reported Running. On a fresh PVC the hook waits for `gravity.db` (bounded, up to 180s) and then
runs a real `pihole -g` (72k domains, network downloads). `apps-wave-0` is `wait: true` and
`apps-wave-1` `dependsOn` it, so a slow cold start **delays the app tier**. The steady-state path
exits in about a second (scenario 1), so this is a rebuild/first-boot cost only — but it is not
measured, and the stub in testing means `pihole -g`'s real duration here is unknown.
- **Whether `pihole -g` succeeds from inside the hook's environment.** Its failure is caught and the
existing `gravity.db` retained, so a failure degrades rather than breaks — untested live.
- **Concurrency.** Nothing serialises the hook against FTL's own scheduled gravity update. SQLite
handles the locking, and the hook's writes are three small statements, but no interleaving was
exercised.
- **MULTI-REPLICA — PHASE 2, NOT STARTED.** This branch only removes the blocker. The remaining work,
and it is substantial: a `replicas`/placement field on the ServiceDefinition schema (currently
`additionalProperties: false` with no such field), `render.py` emitting a StatefulSet with
`volumeClaimTemplates` (the config PVC is **ReadWriteOnce**, so a 2-replica Deployment cannot
work), pod anti-affinity, a PodDisruptionBudget (there is none today), re-rendered goldens, and
`conformance/**` updates — the last making phase 2 sensitive-path as well. The PVC rename a
StatefulSet forces will discard the existing `gravity.db`, which is only safe **because** of this
branch, and should be done only after this one is confirmed working in-cluster.
- **The real risk the operator named is NOT fixed by phase 2 either.** Replicas address *node*
failure. LAN DNS still depends on the cluster as a whole — etcd quorum, Cilium, Longhorn, a bad
reconcile. Quantified on 2026-10-04: with `tolerationSeconds=300` plus node-NotReady detection
plus a Longhorn RWO force-detach, a failure of the node running the pod is roughly **6-10 minutes
of no LAN DNS**; a failure of the node holding the `pihole-dns-*` L2 lease (currently a
*different* node — lease on k3s-prod-2, pod on k3s-prod-1) is tens of seconds. An out-of-cluster
secondary resolver carrying the same `address=` lines is the only option that removes the
dependency, and it was considered and not chosen.
- **Port 80 on the `pihole` Service is still published** and still closed only by policy. Not narrowed.
+117
View File
@@ -0,0 +1,117 @@
---
date: 2026-10-04
branch: docs/pihole-ha-verified
title: PiHole HA is VERIFIED on live prod — including a failover drill with zero dropped DNS queries
---
## What changed
**No manifests changed. This entry exists to correct the log.** The three PiHole/Plex entries merged
earlier today each say, correctly at the time, that nothing had been reconciled and that the outcome
was unverified:
- `decisions/2026-10-04-pihole-declare-state.md` — "Nothing here has been reconciled."
- `decisions/2026-10-04-pihole-multi-replica.md` — "Nothing has been reconciled. No StatefulSet
exists, no second replica has ever run, the anti-affinity has never been evaluated..."
All three are now **merged, reconciled and measured**, so those sentences are stale in exactly the way
§2b of CLAUDE.md warns about — a reader who found them would conclude the work is unproven. This entry
is the current state; it does not edit the earlier files (separate files, nothing to merge).
Delivered and live as of 2026-10-04, from PRs #43 (plex tcp/32400), #44 (declare PiHole state) and
#45 (PiHole 2 replicas + the mountPath bug):
- PiHole is a **2-replica StatefulSet**, one pod per node, with per-replica Longhorn volumes, a
PodDisruptionBudget, and an `up` SLI that follows the rendered workload kind.
- PiHole's admin password and adlists are **declared in git** and reproduce onto a fresh volume.
- PiHole's config PVC is mounted where PiHole actually writes (`/etc/pihole`), which it was not before.
## What I verified
All measured against live prod on 2026-10-04, after the merge of #45 (`dce20b7`).
**Reconcile.** `GitRepository` artifact `main@sha1:dce20b7`; `apps-wave-0`
`lastAppliedRevision=main@sha1:dce20b7`, `Ready=True/ReconciliationSucceeded`. It completed in roughly
**90 seconds**, well inside the Kustomization's `timeout: 5m` — the risk flagged in the multi-replica
entry ("whether `pihole -g` finishes inside the 5 m timeout") did **not** materialise. A transient
`Ready=False/DependencyNotReady: platform-config` seen mid-flight was the dependency cascade, not a
failure; `platform-config` was already `Ready=True` at the new revision moments later.
**Topology and objects.**
| Measured | Value |
|---|---|
| pods / nodes | `pihole-0` → **k3s-prod-3**, `pihole-1` → **k3s-prod-2** (2/2 Running each) — the required anti-affinity was satisfied |
| per-replica claims | `config-pihole-0`, `config-pihole-1` — both `Bound`, 2Gi |
| old `pihole-config` PVC | **pruned** (it held only `lost+found`, so nothing was lost) |
| old `pihole` Deployment | **pruned** — `No resources found in pihole namespace` |
| PodDisruptionBudget | `minAvailable 1`, `disruptionsAllowed 1`, `currentHealthy 2` |
**The mountPath bug is fixed.** `df` inside both pods reports `/etc/pihole` on
`/dev/longhorn/pvc-...` with **6.9 M used** — previously it was on `overlay` while the Longhorn volume
sat unused at `/data/config`. `backup.policy: daily-offcluster` now has real content to back up
instead of reporting success over an empty volume.
**Phase 1's declarations reproduce onto a FRESH volume — the point of doing it first.** Both replicas
booted with an empty `/etc/pihole`:
- **72,234 gravity domains** on each, rebuilt from the adlist declared in `catalog/pihole.yaml`.
- **The declared SOPS secret authenticates on both pods.** `POST /api/auth` using the container's own
`FTLCONF_webserver_api_password` returned `"valid":true` on `pihole-0` **and** `pihole-1` — so
Secret → env → FTL's own hashing → working login, identically on both. (The secret's value was never
printed; an earlier reading of `pwhash` as "unset" was a quoting bug in the check, disproven by an
empty password being rejected with `password incorrect` and then by this positive test.)
**User-facing behaviour through the VIP (192.168.2.23), from a LAN host.**
plex.home.keithjackson.dev -> 192.168.2.20 split-horizon intact
home.keithjackson.dev -> 192.168.2.20
unifi.home.keithjackson.dev -> 192.168.2.21
google.com -> 142.250.115.102 recursion to 8.8.8.8 works
doubleclick.net -> 0.0.0.0 blocking LIVE in FTL, not just in the DB
googleadservices.com -> 0.0.0.0
12 consecutive queries -> 12/12 answered
**THE FAILOVER DRILL — observed by the operator, 2026-10-04.** A single StatefulSet replica was
deleted. It was recreated in **~30 seconds**, and **DNS never dropped a request** throughout. This is
the first direct evidence that the replica count delivers what it was added for, rather than merely
rendering correctly.
**The SLI is not inert, and re-deriving it mattered.** Measured in Prometheus:
`kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}` returns **1 series,
value 2**; `kube_deployment_status_replicas_available{namespace="pihole"}` now returns **0 series**;
and Prometheus has `pihole-down` loaded as `state=inactive health=ok` with the StatefulSet expression.
Had the rule stayed Deployment-shaped, its `or absent(...)` branch would have matched the now-absent
series and **fired permanently** — a standing false page rather than silence. That is the concrete
payoff of defaulting `livenessWorkload.kind` from the rendered kind.
## What I did NOT verify
- **NODE failure, as opposed to pod failure.** The drill deleted a pod, which does not exercise the
`tolerationSeconds: 300` path at all — that timer only applies when a node goes NotReady. The
mechanism protecting DNS is the same one just proven (the surviving replica plus
`externalTrafficPolicy: Cluster` on the DNS LoadBalancers), so user-visible impact should likewise be
nil; what the 300 s still delays is the *replacement* replica, and during that window there is no
redundancy left. Untested. Pulling power from a node is the only real test.
- **DNS during the cutover itself.** I was not sampling resolution while #45 reconciled, so whether
there was a brief gap between the Deployment being pruned and the StatefulSet becoming Ready is
**unmeasured**. The drill above covers the steady-state failover case, not that one-off transition.
- **Two-node loss.** The hard anti-affinity means `replicas: 2` needs two schedulable nodes; losing
two would leave a replica `Pending` and, with `wait: true`, stall the app tier. Reasoned, not
exercised — and it coincides with etcd quorum loss on a 3-node control plane.
- **Longhorn capacity headroom.** Two 2Gi claims at 3 replicas each is ~12Gi where there was ~6Gi.
Both claims are `Bound` and healthy; free space was not checked.
- **Query history is now split per replica** and the admin UI shows only whichever pod answered.
Adlists and password are identical, so blocking and auth are consistent; the *reporting* is not, and
whether that is acceptable long-term is the operator's call.
- **The residual risk is unchanged, and it is the larger one.** Replicas address node failure. LAN DNS
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
outside the cluster carrying the same `address=` lines removes that dependency. Considered and not
chosen (operator decision, 2026-10-04).
- **Plex remote access (#43) is NOT covered by this entry.** Its two manual steps (the WAN tcp/32400
forward and Plex's "manually specify public port") and the outside-in path remain as
`decisions/2026-10-03-plex-remote-access-l4.md` describes them. The operator reported it working;
I have not measured it from off-LAN.
- **`--emit-waves` drift is still open.** Re-running it still wants to onboard `vm-demo.yaml` and
`keycloak.yaml`, both listed as intentionally not onboarded in `ci/render-golden.sh`. Unfixed; see
`decisions/2026-10-03-plex-remote-access-l4.md`.
@@ -0,0 +1,145 @@
---
date: 2026-10-04
branch: feat/pihole-multi-replica
title: PiHole runs two replicas on different nodes — and its config PVC was mounted where PiHole never writes
---
## What changed
Phase 2 of making LAN DNS survive a node failure (phase 1:
`decisions/2026-10-04-pihole-declare-state.md`). PiHole was one pod on one node, which for a
whole-house resolver cost more than "until it reschedules" — measured 2026-10-04:
| Failure | Recovery |
|---|---|
| the node running the pod dies | ~40s to mark NotReady + `tolerationSeconds: 300` before eviction + reschedule + a Longhorn RWO force-detach → **roughly 6-10 minutes with no LAN DNS** |
| the node holding the `pihole-dns-*` L2 lease dies | Cilium re-elects an announcer, tens of seconds |
Those were **different** nodes (lease on k3s-prod-2, pod on k3s-prod-1), so two of three nodes were
single points of failure for LAN DNS with wildly different blast radii.
**A new schema field, because this is intent and not a primitive.** `availability.replicas`
(schema **1.10.0**, mechanism in `schemas/renderer-contract.md` `### availability`). `1` or absent
renders a Deployment exactly as before — every pre-existing golden is byte-identical, verified. `>1`
renders, together:
- a **StatefulSet** instead of a Deployment. This is forced, not preferred: a longhorn PVC is
**ReadWriteOnce**, and one RWO claim cannot attach on two nodes, so a 2-replica Deployment does not
degrade — the second pod never schedules.
- longhorn storage as **per-replica `volumeClaimTemplates`** (`config-pihole-0`, `config-pihole-1`);
the standalone PVC is no longer emitted, since it would be an orphan nothing mounts. A `bulk`
entry stays a standalone PVC — it is a shared static NFS export, not per-replica state.
- **required** `podAntiAffinity` on `kubernetes.io/hostname`. `preferred` would let both replicas
land on one node and deliver none of the intended tolerance while reporting healthy.
- `podManagementPolicy: Parallel`, so a cold start costs the max of the replicas' first-boot work
rather than the sum — `apps-wave-0` has `timeout: 5m`.
- a **PodDisruptionBudget**, `minAvailable: 1`, so a node drain cannot take the last replica.
- `observability.livenessWorkload.kind` defaulting to `StatefulSet`, so the `up` SLI reads
`kube_statefulset_status_replicas_ready`. A Deployment-shaped rule against a StatefulSet
`absent()`s forever — an alert that can never fire.
`catalog/pihole.yaml` sets `replicas: 2`, not 3: two tolerates any single node loss and leaves a
spare node for the evicted replica under the hard anti-affinity. Three would pin one per node with no
spare, so a single node failure would leave a replica permanently `Pending` and — `apps-wave-0` being
`wait: true` — stall the app tier for as long as the node was down.
### The bug found on the way, and fixed here
**PiHole's config PVC was mounted at a path PiHole never writes, so it held nothing and all of its
state was ephemeral.** With no explicit `mountPath` the renderer defaults to `/data/<name>`; PiHole
keeps everything in `/etc/pihole`. Measured on live prod 2026-10-04: `/etc/pihole` (7.1 M —
`gravity.db`, `pihole.toml`, the query DB) was on the **overlay** filesystem, while the 2 Gi Longhorn
volume sat at `/data/config` holding `lost+found` and 24 K of nothing. Consequences, all live until
now:
- every restart silently discarded PiHole's entire state and re-downloaded 72 k gravity domains;
- `backup.policy: daily-offcluster` has been faithfully backing up an **empty** volume, which is
worse than no backup because it reports success;
- it also means phase 1 was understated: that state was not merely undeclared, it was **ephemeral**.
Same class of bug `catalog/plex.yaml` documents for its own `config` entry. Fixed in this branch
rather than its own because with the wrong path the new per-replica volumes would be two 2 Gi
Longhorn volumes (×3 Longhorn replicas each) holding only `lost+found` while all state stayed
ephemeral — paying for persistence and getting none. It also removes this change's main operational
risk: with `gravity.db` persisted, a restart is cheap instead of re-downloading 72 k domains inside a
wave with a 5 m timeout.
## What I verified
- **Back-compatibility, the thing most likely to break quietly:** after the renderer change and
before `catalog/pihole.yaml` declared anything, the canonical
`render.py --all catalog -o apps --wave-layout --no-image-automation` produced **zero diff** across
the whole `apps/` tree. `render-golden` passes.
- **The new behaviour is pinned by ~20 new `render.py --selftest` assertions** (so `ci/render-golden.sh`
hard-fails on a regression, and no `conformance/**` change was needed): `replicas: 1` still renders a
Deployment + standalone PVC + no PDB; `replicas: 2` renders a StatefulSet and **no** standalone
longhorn PVC, carries the replica count, omits `strategy`, sets `Parallel` and `serviceName`, names
the claim template exactly as the mounted volume, keeps RWO/longhorn on it, does **not** duplicate
that volume in `pod.volumes`, still mounts it on the container, renders required hostname
anti-affinity, renders exactly one `minAvailable: 1` PDB, and switches the SLI to
`kube_statefulset_status_replicas_ready`. A mixed longhorn+bulk case asserts the bulk share stays a
standalone PVC and an ordinary pod volume.
- **Rendered pihole output, read back:** `replicas: 2`, `serviceName: pihole`,
`podManagementPolicy: Parallel`, no `strategy`, claim template `config` (RWO/longhorn/2Gi),
anti-affinity `kubernetes.io/hostname`, `pod.volumes: []`, mount `config -> /etc/pihole`,
PDB `minAvailable: 1`, SLI `kube_statefulset_status_replicas_ready`.
- **The `/etc/pihole` mount is safe, evidenced from the image's own init rather than assumed:**
`/usr/bin/start.sh` is PID 1 and runs as **uid 0**; its line 5 is
`chown -R pihole:pihole /etc/pihole/ /var/log/pihole/`, so a fresh root-owned volume has its
ownership fixed before anything else starts (**no `fsGroup` needed, and one would be inert**); it
calls `migrate_gravity`, whose `migrate_to_database()` creates the gravity database when
`[ ! -e "$gravityDBfile" ]`; FTL writes `pihole.toml` itself from the `FTLCONF_*` env; and the
image's own default for `files.macvendor` was moved **out** of that directory, which is what a
maintainer does to a path they expect to be a volume.
- **The PVC really is unused:** `df` showed `/etc/pihole` on `overlay` and
`/dev/longhorn/pvc-849edb86-...` on `/data/config`; `ls -A /data/config` → `lost+found` only.
- **The gate caught a real regression and it is fixed.** The first run came back **rc 1**:
`alert-rules-unit-tested` failed because `apps/wave-0/tests/app-slis.test.yaml` fed pihole a
`kube_deployment_status_replicas_available` series and expected a `deployment: pihole` label. Three
input series, the firing expectation and the file header were updated;
`platform/monitoring/tests/run.sh apps/wave-0/tests/app-slis.test.yaml` → **SUCCESS** (6 rules).
- **Schema:** `check-jsonschema` passes over all of `catalog/*.yaml`.
- **Toolbox gate:** rc **3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
`render-golden`, `l1`, `approval-policy` all **pass**. The rc 3 is the two environmental skips —
`gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE`
unset). **Not green; reported as rc 3.**
- **No sensitive paths touched**, so unlike the previous two branches this one needs no
`/approve-override` — confirmed against `infra/gitea/approval-policy.json`.
## What I did NOT verify
- **Nothing has been reconciled.** No StatefulSet exists, no second replica has ever run, the
anti-affinity has never been evaluated by the scheduler, the PDB has never been consulted, and the
`/etc/pihole` mount has never been exercised on a real pod. Everything above is the rendered
artifact plus the image's own source.
- **THE ROLLOUT WILL BRIEFLY DROP LAN DNS, and I have not measured for how long.** `postStart` must
complete before a container is reported Running, so a pod is not Ready — and the Service has no
endpoints — until phase 1's adlist hook finishes. On this transition both replicas start with an
empty `/etc/pihole`, so the hook finds a fresh `gravity.db` with zero adlists and runs a real
`pihole -g` (72 k domains, network download) before either pod becomes Ready. **Do this at a quiet
time.** Two mitigating facts: every pihole restart has *already* paid this cost (state was
ephemeral), and after this change `gravity.db` persists so later restarts should not. If it turns
out slow, backgrounding the gravity rebuild inside the hook would decouple readiness from it, at
the cost of a window where DNS resolves but does not block — not done here.
- **Whether `pihole -g` finishes inside `apps-wave-0`'s 5 m `timeout`.** `Parallel` was chosen to
make this the max rather than the sum of two first boots, but the real duration is unmeasured (it
was stubbed during phase 1's testing). If the wave times out, the Kustomization reports failed even
though the pods may come up seconds later.
- **The old PVC's deletion.** `apps-wave-0` has `prune: true`, so removing `pihole-config` from the
rendered output should delete it. That destroys nothing (it holds `lost+found`), but I have not
observed the prune, and I did not check whether Longhorn retains the backing volume afterwards.
- **The transition overlap.** For a moment the pruned Deployment's pod and the new StatefulSet pods
may both match the Service selector. Both are configured identically from git so this should be
benign — unobserved.
- **Longhorn capacity.** Two 2 Gi claims at 3 Longhorn replicas each is ~12 Gi of real storage where
there was ~6 Gi. Not checked against free space.
- **Whether two independent query databases are acceptable to the operator.** Stats and query history
now split per replica, and the admin UI shows only the pod that answered. Adlists and password are
identical (phase 1), so blocking and auth are consistent; the *reporting* is not.
- **The residual risk is unchanged and is the larger one.** Replicas address node failure. LAN DNS
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
outside the cluster carrying the same `address=` lines removes that. Considered and not chosen
(operator decision, 2026-10-04).
- **`tolerationSeconds: 300` is untouched.** With two replicas the surviving one serves immediately, so
the 5-minute eviction delay no longer gates recovery — but it still delays the *replacement* replica,
and during that window there is no redundancy left.
@@ -0,0 +1,139 @@
---
date: 2026-10-04
branch: fix/plex-advertise-plexdirect
title: Plex vanished from the plex.tv account — every advertised connection was uncompletable; advertise the plex.direct name instead of the bare VIP
---
## What changed
Reported symptom: the server no longer appears on the operator's plex.tv account; `plex.home.keithjackson.dev`
shows "not authorized"; connecting straight to the VIP + port works.
**Root cause, measured: `customConnections` held two hand-typed URLs and NEITHER could ever complete,
so Plex had nothing publishable.** Because `customConnections` OVERRIDES Plex's own connection
publishing, the server ended up advertising only dead endpoints. Live `Preferences.xml`, 2026-10-04:
customConnections="https://plex.home.keithjackson.dev:32400/,https://192.168.2.24:32400/"
ManualPortMappingMode="1" ManualPortMappingPort=<ABSENT>
- `https://192.168.2.24:32400` — **TLS can never validate.** Plex serves 32400 with
`CN=*.130072f20c334d6c8f63d0dfef87309b.plex.direct` and **no IP SAN**, so a bare-IP https URL fails
for every client. Not theoretical: Plex's log carries a continuous storm of
`CERT: incomplete TLS handshake from <node> ... sslv3 alert certificate unknown`, every few seconds,
from **real LAN clients** arriving through the new `plex-l4-tcp` VIP.
- `https://plex.home.keithjackson.dev:32400` — **nothing listens.** The shared Gateway serves that host
on 443; measured `http=000` (connect failed) against `192.168.2.20:32400`.
Consequence chain, each link measured: no completable connection -> plex.tv could publish nothing
(`MyPlex: mapping state set to 'Mapped - Not Published'`, repeatedly) -> Plex fell back to **Plex Relay**
(`startRelay`, remote forward to 127.0.0.1:32401) -> **the server disappeared from the account**, and
clients reported "not authorized".
The fix advertises Plex's own `<dashed-ip>.<serverhash>.plex.direct` form, which is public DNS resolving
to the embedded address — so it points at the LAN VIP *and* matches the wildcard cert.
- **`catalog/plex.yaml`:** `ADVERTISE_IP: ${PLEX_ADVERTISE_URL}` added, replacing the long-standing
DEFERRAL note. The deferral had weighed only the L7 Gateway URL; the operator chose the native L4 path
on 2026-10-03, so the open question was never "Gateway or not" but "which URL has a valid cert".
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_ADVERTISE_URL` knob (+ substitution-inventory comment
updated to name `plex: PLEX_ADVERTISE_URL`). The base default is an explicit **non-functional
placeholder** — dev is decommissioned and has no claimed server, and
`StrictPostBuildSubstitutions` makes an undefined var a hard failure, so the default only has to exist
and be correctly shaped.
- **`clusters/prod/kustomization.yaml`:** prod value
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`.
- **`apps/wave-0/plex.yaml`:** re-rendered with the canonical command
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`). Two added
lines, nothing else.
**Why a whole-URL cluster-var and not something built from `${PLEX_L4_VIP}`:** postBuild substitution is
plain envsubst and cannot turn `192.168.2.24` into `192-168-2-24`, and `<serverhash>` is the server's
certificate identity, derivable from nothing in this tree. The dashed IP and `${PLEX_L4_VIP}` are two
halves of one address and **nothing checks that they agree** — noted in both files.
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK it.**
It needs the operator's `/approve-override <reason>` posted as a PR **review** of type "Comment".
Deliberately not posted here: CLAUDE.md §1 forbids an agent clearing this on its own PR, and the agent
account holds `owner`, so the prohibition is a rule rather than a limit.
## What I verified
All measurements against live prod, 2026-10-04, read-only. Secrets handled per CLAUDE.md §6: preference
attributes were read by name-scoped grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`),
and the token was reported only as `<PRESENT,len=22>`.
- **THE FIX ITSELF, with full TLS verification (no `curl -k`)** — the one measurement the whole change
rests on:
- `https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400/identity` -> **`http=200`, TLS verify OK**
- `https://192.168.2.24:32400/identity` -> **`http=000`, cert validation FAILED**
- that name resolves to `192.168.2.24` (`getent hosts`), confirming the dashed-IP mechanism works from
inside this network and is not blocked by DNS-rebinding protection on the resolver path tested.
- **The server is still claimed — this was never a lost claim.** `/identity` -> `claimed="1"` with the real
`machineIdentifier`; `PlexOnlineToken` present, `PlexOnlineUsername="jakman85"`.
- **The certificate, read off the live listener:** exactly one SAN,
`DNS:*.130072f20c334d6c8f63d0dfef87309b.plex.direct`. No IP SAN. This is why the bare-IP URL fails.
- **The publish payload Plex actually sent to plex.tv**, which is what proves the connection list was the
problem rather than a guess: `PUT https://servers.plex.tv/devices/<id>?Connection[][uri]=https://plex.home.keithjackson.dev:32400&Connection[][uri]=https://192.168.2.24:32400&Connection[][uri]=http://136.51.115.252:32400&Connection[][uri]=http://10.0.2.136:32400&httpsEnabled=1...`
— all four unreachable from a LAN client or from plex.tv.
- **`Mapped - Not Published`** at every reachability check across the whole log, and an active **Plex Relay**
session — the documented fallback when nothing is directly publishable.
- **The `certificate unknown` TLS failures come from real client traffic, not a probe.** The source
`10.0.1.78` resolves via `cilium-dbg bpf ipcache get` to **identity 1 = `reserved:host`**, i.e. LAN
clients SNAT'd through `plex-l4-tcp` (`externalTrafficPolicy: Cluster`) on prod-3.
- **The Gateway is NOT broken, and my first reading of it was wrong.** A 403 `Access denied` from
`server: envoy` on the Gateway path turned out to be an artifact of probing from inside the plex pod:
**all 16** Gateway hostnames return 403 from there, because Cilium's Gateway preserves the original
source identity and enforces the destination's ingress policy. Plex's own log shows a concurrent,
**working, signed-in** browser session through the Gateway returning 200s. The HTTPRoute is
`Accepted=True`/`ResolvedRefs=True` with no filters, and it is the only route claiming that hostname.
- **Cilium is not denying the Gateway->Plex hop:** `cilium-dbg bpf policy get 3796` shows
`Allow Ingress reserved:ingress 32400/TCP`, PROXY PORT `NONE`, with 141k packets counted. No L7/HTTP
rules and no CCNPs exist anywhere in the cluster.
- **In-cluster consumers were never affected:** Seerr's recently-added scans and watchlist syncs complete
cleanly with no auth errors; there are **zero** HTTP 401s in Plex's log (the five ` 401 ` hits are
transcoder segment numbers).
- **`ADVERTISE_IP`'s mechanism, in the running image rather than from docs:**
`/etc/cont-init.d/40-plex-first-run` line 127 does `setPref "customConnections" "${ADVERTISE_IP}"`,
outside the claim guard, and `/.firstRunComplete` (lines 9/139) is on the container overlay, not the
`/config` PVC — so it re-applies on every pod start. Editing the env rolls the Deployment, which applies it.
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
**pass**. The rc 3 is the two known environmental skips — `gitea-settings` (no local
`SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3.**
- **`render-golden` passing is the specific proof** that the catalog edit and the committed golden agree;
the rendered diff is exactly two lines and no other service's manifest moved.
## What I did NOT verify
- **THE OUTCOME. Nothing here has been observed to put the server back on the plex.tv account.** The
change is unreconciled: `ADVERTISE_IP` is absent from the live Deployment, the live
`customConnections` still holds the two broken URLs, and Plex has not been restarted. What is proven is
that the *new URL answers 200 with a valid certificate* and that *every currently advertised URL cannot
complete* — a necessary-and-plausibly-sufficient argument, not a demonstrated fix.
- **Whether plex.tv will publish and list the server once this lands.** plex.tv's decision is made
off-network and cannot be measured from here. A LAN-only connection may still leave the account showing
the server as remotely unreachable even when local clients work.
- **REMOTE (off-LAN) ACCESS IS NOT FIXED BY THIS, and may still be fully broken.** The advertised URL
resolves to an RFC1918 address, so it serves LAN clients only. The off-repo WAN tcp/32400 port-forward
(`runbooks/plex-remote-access.md`) is still **unverified** — `http://136.51.115.252:32400` was
unreachable from inside the LAN, but 2026-10-03 established there is no NAT hairpin here, so that is
**inconclusive**, not proof of absence. This repo has no off-LAN vantage point.
- **`ManualPortMappingMode="1"` with `ManualPortMappingPort` ABSENT is left as-is.** Manual public-port
mapping is enabled with no port set — a half-configured state I did not change and whose exact effect on
publishing I did not isolate. There is **no GitOps path** to it (2026-10-03: the image supports no
`PLEX_PREFERENCE*` of any kind), so it is an operator UI step.
- **Whether the operator intended the hand-typed URLs** to override the 2026-10-03 native-L4 design, or
typed them while debugging. I did not alter live preferences; the env var will overwrite
`customConnections` on the next pod start, which is a deliberate transfer of ownership from the web form
to git and will **silently discard** anything typed there since.
- **The `plex.direct` hash's stability.** Treated as server identity and read once from the live cert. I
did not establish what reissues it, so this URL could go stale with no alert; nothing in CI or the
conformance suite checks that the dashed IP still matches `${PLEX_L4_VIP}` or that the hash is current.
- **Resolver behaviour for the whole LAN.** `plex.direct` resolved correctly from a *pod* (cluster DNS
path). LAN clients resolve via PiHole, and dnsmasq's `stop-dns-rebind` would reject a public answer
carrying a private address. I did **not** test resolution from a LAN client, and this is the most likely
way the fix still fails.
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not a
verdict: this PR touches `clusters/**`, so CI will block pending the operator's override.
- **Why PMS restarted.** The process came up at 05:26 UTC while the pod is 2d2h old with 0 restarts, so
something restarted Plex inside the container. Unexplained and not investigated.
@@ -0,0 +1,126 @@
---
date: 2026-10-04
branch: fix/plex-unblock-app-tier
title: Unblock the app tier — my Certificate stalled apps-wave-0, and the advertised https URL had no servable certificate
---
## What changed
**I broke the app tier with PR #48 and this reverts the part that did it.** Two faults, both mine,
both measured after the merge:
1. **Kyverno blocks cert-manager in app namespaces.** The temporary private-key Secrets
(`plex-vip-<random>`) were denied by `secret-provenance/secrets-must-be-flux-managed`:
resource Secret/plex/plex-vip-rp4qj was blocked due to the following policies
secret-provenance:
secrets-must-be-flux-managed: Secrets in application namespaces must be delivered by
Flux from SOPS-encrypted git ... or be exempt controller-generated Secrets.
The policy matches every Secret in a namespace labelled
`homelab.keithjackson.dev/app-namespace: true` and excludes only Secrets labelled
`homelab.keithjackson.dev/secret-managed-by: controller` or carrying a Flux label. **The `plex`
namespace has that label; the `gateway` namespace — where the working wildcard cert lives — does
NOT.** That asymmetry is why the existing Certificate was never affected and mine was, and I did
not check it. cert-manager's temp key Secrets carry neither exempting label, and `secretTemplate`
cannot label them (the keymanager controller creates them, not the Certificate template). Result:
**no CertificateRequest, Order or Challenge was ever created.**
2. **Flux health-checks the Certificate — guarding the pod was not enough.** `optional: true` did
exactly what it was designed to do: Plex rolled and ran fine without the Secret (verified). But
`apps-wave-0` is `wait: true`, so Flux waits on every applied object with a known health type:
health check failed after 5m0.045564329s: timeout waiting for:
[Certificate/plex/plex-vip status: 'InProgress']
apps-wave-0 never went Ready, and apps-wave-1/-2 plus everything downstream of platform-config
sat in `DependencyNotReady` behind it. I reasoned about the pod and missed the object.
A third, independent problem the same PR introduced: **the advertised URL was `https://` with no
servable certificate.** Plex answers 32400 with its `*.<serverhash>.plex.direct` cert, which cannot
cover `plex-vip.${BASE_DOMAIN}` — the precise failure mode of the outage earlier that day.
- **`apps/wave-0/plex-cert.yaml` DELETED**, and `plex-cert.yaml` removed from
`apps/wave-0/kustomization.yaml` `resources`, so Flux prunes the stuck Certificate and the health
gate clears. **Deleted rather than merely unregistered** — my first attempt kept the file as
"tracked but not onboarded" (the shape `keycloak.yaml` and `vm-demo.yaml` use) and **the repo's own
gate rejected it**: `tls-lifecycle-automated` (R-TLS-02) fails any Certificate in the tree that no
Flux path reconciles — *"nothing reconciles it, so a deleted Certificate/issuer/controller would
stay deleted until a human re-applied it."* That pattern is legal for a Deployment and not for a
TLS object, which is a distinction I had not known.
- **`catalog/render.py`:** the now-dangling `plex-cert` EXTRA_UNITS entry removed.
- **The re-enable recipe is recorded at the call site** — in `catalog/plex.yaml` beside the scheme
it is coupled to — rather than in the deleted file: the Kyverno exemption, the non-gating
Kustomization, and the `https` flip, as one change.
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → **`http://`**`plex-vip.${BASE_DOMAIN}:32400`, explicitly
temporary, with the restore condition written at the call site.
**Everything else from PR #48 is kept and working** — the hostname design, the PiHole split-horizon
answer, the `LanNetworksBandwidth` hook, the `optional` schema field, and the TLS env (inert without
the Secret, so re-enabling is additive).
## What I verified
Live prod, 2026-10-04, read-only, after the #48/#50 merges.
- **The stall and its exact cause**: the health-check timeout message above, and the full dependency
cascade (`cilium` → "dependency 'flux-system/flux-system' revision is not up to date", everything
else `DependencyNotReady`). The root `cilium` wait was a normal revision-gated cascade; the
*Certificate* was the real blocker.
- **The Kyverno denial text, verbatim**, from the cert-manager controller log, repeating every ~30s
across at least 15 distinct Secret names.
- **The policy's own structure**, read from `platform/kyverno/admission/secret-provenance.yaml`:
match = Secrets in `app-namespace: true` namespaces; exclude = `secret-managed-by: controller` OR
a `kustomize.toolkit.fluxcd.io/name` label; validate = unconditional `deny`.
- **The namespace-label asymmetry**: `plex` carries
`homelab.keithjackson.dev/app-namespace: "true"`; `gateway` does not.
- **cert-manager's controller ServiceAccount is `cert-manager`** in namespace `cert-manager` — the
identity a future exemption should name.
- **`optional: true` worked**: the new pod (`plex-c4f87c84d-zgjq5`) is `1/1 Running` with no Secret
present, and `/config/certs` was never created.
- **The postStart hook RAN and took effect** — the thing the whole hook was written for:
`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is now set in the live `Preferences.xml`, and
`customConnections="https://plex-vip.home.keithjackson.dev:32400"` was applied from the env. The
certificate prefs are correctly **ABSENT**, i.e. the hook's "no bundle → leave them alone" guard
behaved as designed.
- **PiHole's split-horizon answer is correct**, asked of `pihole-0` directly:
`plex-vip.home.keithjackson.dev -> 192.168.2.24`, alongside `plex -> .20` and `crafty-mc -> .22`,
and the live `/etc/pihole/dnsmasq.conf` carries
`address=/plex-vip.home.keithjackson.dev/192.168.2.24`. **This corrects my own earlier check**,
which read `.20` and which I nearly reported as a failure — that test resolved via CLUSTER DNS
(CoreDNS) from inside a pod and never consulted PiHole at all.
- **The gate caught my first attempt and I fixed it rather than working around it.** Keeping the
file unregistered produced `MERGE GATE: BLOCKED` (rc 1) with
`CHECK FAIL (tls-lifecycle-automated): 1 of 612 assertions failed`. After deleting the file and
the dangling EXTRA_UNITS entry: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
`render-golden`, `l1` and `approval-policy` all **pass** — the rc 3 being only the two known
environmental skips (`gitea-settings`, `conformance` L2/L3). **Not green. Reported as rc 3.**
## What I did NOT verify
- **Whether this actually unblocks the tier.** Unreconciled at time of writing. The reasoning is
that pruning the Certificate removes the only failing health check, but I have not seen
apps-wave-0 go Ready.
- **Whether `http://` restores client connectivity.** PiHole resolves the name and Plex serves 32400
(both measured), but I have not watched a Plex client complete a connection over the advertised
URL, and I did not re-check whether plex.tv publishes it.
- **THE REMOTE BADGE — the symptom that started this.** `LanNetworksBandwidth` is set, but the log
still tags traffic `Allowed Network (WAN)`: **82 WAN, 0 LAN**, with entries continuing after the
pref was written. So either Plex needs a restart for it to take effect, or that log tag is not
driven by this pref at all. **I do not know which, and the user-visible badge is the only real
test** — it is in the Plex client, which I cannot see.
- **The Hubble flow export is NOT active.** The cilium agents are still the pre-merge pods, so the
`filePath: stdout` question is still open and no flow has been exported. The Cilium rollout — and
its LAN-DNS risk, since prod-3 holds the Gateway, PiHole-DNS and Plex VIPs — is still AHEAD.
- **Whether a cert-manager exemption is the right shape**, or whether it widens
`secret-provenance` more than the operator wants. It is `platform/kyverno/**`, a sensitive path,
and deliberately not attempted here.
- **Whether the pruned Certificate leaves residue** — I did not check for orphaned
`plex-vip-<random>` Secrets or CertificateRequests after the prune. Note Kyverno DENIED every one
of those Secrets, so there may be nothing to clean, but that is an inference, not a check.
- **Whether `tls-lifecycle-automated` would also have objected to the Certificate living in a
`wait: false` Kustomization** (the recommended re-enable shape). It demands a reconciling Flux
path, which that provides, but I did not test it.
- **The unencrypted advertised URL.** `http://` is tolerable only while remote access does not work
(no WAN forward). Nothing enforces that assumption, and nothing will complain if `http` is still
there the day the port-forward appears.
@@ -0,0 +1,151 @@
---
date: 2026-10-04
branch: feat/plex-vip-hostname-and-lan-networks
title: Plex — one advertised hostname for local and remote, its own LE certificate, and a LAN definition so LAN clients stop being reported as remote
---
## What changed
Two operator-reported symptoms, after the 2026-10-04 "server missing from the account" fix
(decisions/2026-10-04-plex-advertise-plexdirect.md) had restored publishing:
1. **LAN clients were shown as Remote.** Root cause, measured: `LanNetworksBandwidth` was **ABSENT**,
so Plex had no definition of a local network and classified *everything* as WAN — **468 requests
logged `Allowed Network (WAN)` and ZERO `(LAN)`**, including Seerr dialling from inside the
cluster. `allowedNetworks` (the `ALLOWED_NETWORKS` env) only waives **auth**; it does not make a
client local. Two different settings, and only one of them has an env hook.
2. **The advertised URL was a single-purpose, fragile value.** The previous fix advertised
`https://<dashed-ip>.<serverhash>.plex.direct:32400`, which works, but hard-codes the VIP *and*
the server's certificate hash into a cluster-var — two values nothing could keep in sync.
**Operator design adopted (2026-10-04): ONE hostname, `plex-vip.${BASE_DOMAIN}`, serving both
audiences by split-horizon DNS** — PiHole answers it with `${PLEX_L4_VIP}` on the LAN, Cloudflare
answers it publicly with the WAN address, NATed to the same VIP.
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → `https://plex-vip.${BASE_DOMAIN}:32400`. New
`lifecycle.postStart` hook (level-triggered, same idiom as pihole's and qbittorrent's) that
re-asserts on **every** start: `LanNetworksBandwidth`, and the three `customCertificate*` prefs
after converting the PEM to the PKCS#12 bundle Plex requires. New `PLEX_TLS_CRT`/`PLEX_TLS_KEY`
env from the `plex-vip-tls` Secret, both `optional: true`.
- **`apps/wave-0/plex-cert.yaml` (new, hand-written):** a cert-manager `Certificate` for exactly
`plex-vip.${BASE_DOMAIN}` from ClusterIssuer `letsencrypt-prod`. Non-catalog because the schema has
no field for a Certificate. A single name, not the wildcard: the wildcard's Secret lives in
`gateway` and Secrets are not readable across namespaces, and re-requesting `*.${BASE_DOMAIN}`
would burn a duplicate-certificate slot for no benefit.
- **`schemas/servicedefinition.schema.json` → 1.11.0:** adds `optional` to
`env.valueFrom.secretKeyRef` / `configMapKeyRef`. **This is load-bearing, not tidiness.** The
Certificate must be issued into the `plex` namespace, and that namespace is created by the
rendered `apps/wave-0/plex.yaml` itself — so it cannot be issued in an earlier tier and lands in
the *same* reconcile as the workload. With hard refs, Plex would sit in
`CreateContainerConfigError` until a certificate issued, and `apps-wave-0` is `wait: true` with
`apps-wave-1` dependsOn it, so a slow or failed DNS01 challenge would have **stalled the whole app
tier** — the hazard `catalog/plex.yaml` already documents for its GPU resource.
- **`catalog/pihole.yaml`:** `address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}` added to
`FTLCONF_misc_dnsmasq_lines`. Required, not cosmetic: measured 2026-10-04,
`plex-vip.${BASE_DOMAIN}` resolved to **`192.168.2.20`** (the Gateway VIP) via the zone wildcard,
and a connect to `:32400` there returned **`http=000`**.
- **`clusters/base/cluster-vars.yaml` + `clusters/prod/kustomization.yaml`:** `PLEX_ADVERTISE_URL`
**deleted** — the hostname makes it unnecessary, which removes the cert-hash fragility outright.
`PLEX_L4_VIP`'s note now records its second consumer (pihole).
- **`catalog/render.py`:** `plex-cert` EXTRA_UNITS entry; **`apps/wave-0/kustomization.yaml`:**
`plex-cert.yaml` hand-added (NOT via `--emit-waves`, which would also onboard `keycloak` and
`vm-demo` — the pre-existing drift recorded on 2026-10-03).
**A correction to my own earlier reasoning, recorded because it was wrong in the handoff log.** The
previous entry asserted a bare-IP URL "can NEVER validate" and treated a SAN match as a
*requirement*. The operator pointed out it is not: `httpsRequired=0` and `secureConnections` default
to Preferred, so clients can fall back to cleartext. The measurements still stand (a validating
client did fail, and real clients did send `certificate unknown`) — but the correct framing is that a
matching certificate keeps the remote leg **encrypted**, not that it is required for Plex to
function. That is why this branch obtains a real certificate for the advertised name rather than
choosing the name to suit Plex's built-in one.
**SENSITIVE PATHS — touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK until an
approving review (or the operator's `/approve-override <reason>` posted as a PR *review* of type
"Comment"). Deliberately not self-posted: CLAUDE.md §1.**
## What I verified
Live prod, 2026-10-04, read-only. Secrets per CLAUDE.md §6: prefs were read by name-scoped grep,
the server token was used inside the pod and never printed, and no `Preferences.xml` was dumped.
- **The LAN-classification root cause, directly:** `LanNetworksBandwidth=<ABSENT>`, and request
tagging of **468 × `Allowed Network (WAN)` vs 0 × `(LAN)`** — including `10.0.2.33` (Seerr), an
in-cluster pod. This is the measurement the whole hook rests on.
- **All four prefs the hook sets EXIST on this build**, read from the server's own `/:/prefs`
(token-authenticated, output filtered to ids): `LanNetworksBandwidth`, `customCertificatePath`,
`customCertificateKey`, `customCertificateDomain`, all `type="text"`. I had earlier been unable to
confirm this (an unauthenticated `GET /:/prefs` returns **401**) and said so rather than assuming.
- **There is NO env path for these settings** — the reason the hook exists. Grep of the image's
entire init surface: `setPref` is called for exactly six prefs (`MachineIdentifier`,
`ProcessedMachineIdentifier`, `PlexOnlineToken`, `customConnections`, `allowedNetworks`,
`TranscoderTempDirectory`) and the only `PLEX_*` env it reads are CLAIM/CLAIM_FILE/UID/GID/
UPDATE_CHANNEL and five INFO/HOME vars. No `PLEX_PREFERENCE*` of any kind; zero files mention
`LanNetworks`.
- **The DNS gap was real:** `plex-vip.${BASE_DOMAIN}` resolved to `192.168.2.20` (Gateway VIP), and
`https://192.168.2.20:32400` returned `http=000` (nothing listening). PiHole's wildcard is why.
- **`openssl` is present in the image** (`/usr/bin/openssl`, 3.0.13) — the hook's PEM→PKCS#12
conversion has a tool to run.
- **The issuer exists and works:** ClusterIssuer `letsencrypt-prod` `READY=True` (28d), and the
existing `gateway/wildcard-home` Certificate is `Ready`/not-expired, which is what makes a DNS01
issuance for a new name in this zone a reasonable expectation.
- **The renderer's substitution guard caught a real defect in my own work**, which is recorded
because it is the second time this class of bug has appeared here: my hook's explanatory comment
contained brace-wrapped names, and `assert_substitution_safe` refused to emit
(`'PX' is not a declared cluster-vars key — envsubst will replace it with the EMPTY STRING`).
Reworded; the rendered hook now contains exactly one brace-wrapped name, `BASE_DOMAIN`, verified
by parsing the emitted manifest.
- **A `set -u` bug I introduced and then removed.** With `optional: true` the TLS env vars are
genuinely UNSET when no certificate has issued; under `set -u` the first `[ -n ... ]` would abort
the hook, and a postStart hook exiting non-zero makes kubelet KILL the container — the exact
failure `optional` exists to prevent. Verified by parsing the rendered manifest that no `set -u`
**command** remains (only two mentions in comments).
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
**pass**; the rc 3 is the two known environmental skips (`gitea-settings` — no local
`SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE` unset). **Not green. Reported as
rc 3.** `l0` passing is the specific proof the schema edit admits `optional` and still validates
every catalog file; `render-golden` passing is the proof catalog and committed goldens agree.
- **Only plex and pihole goldens moved.** No other service's manifest changed, and no service was
silently onboarded.
## What I did NOT verify
- **THE OUTCOME, ENTIRELY. Nothing in this branch has been observed working.** It is unreconciled:
no Certificate exists in prod, `plex-vip` still resolves to the Gateway VIP, `LanNetworksBandwidth`
is still absent, and the hook has NEVER RUN. Every claim above is about the inputs, not the result.
- **Whether the certificate will actually ISSUE.** DNS01 for a new name in this zone is inferred from
the wildcard's success, not demonstrated. If the Cloudflare credential or solver is scoped in a way
that fails for this name, Plex will start (by design) with no TLS and the advertised `https://` URL
will be unservable — i.e. **this branch could leave the advertised URL broken in a new way**. First
reconcile must be watched.
- **Whether `openssl pkcs12 -export` produces a bundle PLEX ACCEPTS.** OpenSSL 3 defaults to AES-256
+ SHA-256, which some consumers reject. Untested against Plex. If it is rejected, Plex may fail to
load its TLS listener — the hook guards against a *missing* file, NOT against a present-but-
unreadable one. **This is the single largest unverified risk in the change.**
- **Whether the certificate prefs take effect without a restart.** Plex may need to re-initialise its
listener after `customCertificatePath` changes. If so, the first reconcile applies the prefs and
the SECOND start serves the cert. Converges either way, but the first start may look wrong.
- **Whether `LanNetworksBandwidth` actually flips the Remote badge.** It is the correct setting by
Plex's own documentation and the measured absence explains the symptom exactly, but I did not set it
live and observe a client reclassify.
- **SNAT makes the LAN definition coarser than it looks.** With `externalTrafficPolicy: Cluster`, Plex
sees the ingress NODE's address for everything arriving via the VIP, so once `10.0.0.0/8` and
`192.168.0.0/16` are LAN, GENUINE REMOTE clients arriving that way will ALSO be classified local
and remote bandwidth limits will not bind on them. This is the metering consequence already
accepted on 2026-10-03, now made stronger rather than weaker. Not measured.
- **The public Cloudflare record for `plex-vip` is off-repo and absent from this branch**, as is the
WAN tcp/32400 port-forward. Remote access cannot work until the operator creates both; nothing here
can verify either, and this repo has no off-LAN vantage point.
- **The private key is exposed in the container's environment** (`/proc/1/environ`) because the schema
and renderer support no secret VOLUME — measured: `storage` accepts only
[class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`, and
no rendered app mounts a Secret. A volume would be the better shape; adding secret-volume support
is the clean follow-up and I did not do it.
- **The PKCS#12 passphrase is a hardcoded literal** (`plexvip`) in the rendered manifest. It protects
nothing — Plex needs it in cleartext in `customCertificateKey` and it sits beside the bundle on the
same PVC — but it is worth knowing it is not a secret and was never treated as one.
- **Four places must agree and nothing enforces it:** `ADVERTISE_IP`, the hook's
`customCertificateDomain`, `plex-cert.yaml`'s `dnsNames`, and PiHole's answer. No gate stage or
conformance test checks that they match, or that PiHole's `plex-vip` answer still equals
`${PLEX_L4_VIP}`.
@@ -0,0 +1,123 @@
---
date: 2026-10-05
branch: docs/agents-work-in-their-own-workspace
title: One workspace per agent run — the shared checkout is now documented as off-limits
---
## What changed
Agents have been doing their work in the operator's checkout at
`/var/home/keith/source/home-network-k3s-impl` and clobbering each other. Nothing in the tree
told them not to: worktrees appeared in CLAUDE.md only as §3's *gate workarounds*, i.e. as an
awkward edge case, and `decisions/README.md` asserted worktree isolation as a fact about how
agents already behave rather than as a rule they must follow. Nowhere was the rule stated.
Documentation only — no code, no manifests, no pipeline logic.
- **`CLAUDE.md`: new `## 0. Work in your OWN workspace. Never in the shared checkout.`**
Placed before §1 so the section numbers every other file and handoff entry cites (§1, §2b,
§3, §7) all keep their meaning. It gives the three commands (`git fetch origin main`;
`git worktree add -b <type>/<slug> <path outside the repo> origin/main`; `cd`), an explicit
do-not list for the shared checkout (no `checkout`/`switch`, no edits, no
`add`/`commit`/`stash`/`restore`, no removing others' worktrees), a measurement table, the
"put it outside the repo root" rule, cleanup, what to do if you are already standing in the
shared checkout with edits, and what a PM must put in a brief it writes for another agent.
- **`CLAUDE.md` intro** now says "Start with §0", with the reason: it is the rule you can
break before you have read anything else.
- **`CLAUDE.md` §3** retitled "Running the gate from a worktree" and reframed as the normal
path rather than an edge case, since §0 now makes every gate run a worktree run. Its list
grew from two breakages to three: `spec-immutability`'s skip is *avoidable*, not merely
tolerated, if the `ENGINE` shim also mounts the sibling spec repo read-only and passes
`-e SPEC_REPO=/home-network-k3s`.
- **`CLAUDE.md` §4** gained "One branch, one workspace, one PR".
- **`README.md`**: a fourth non-negotiable rule ("No work in a shared checkout"), and
"Working on this repo" now opens with how to get out of the checkout before describing the
dev container that runs in it.
- **`decisions/README.md`**: the worktree-isolation sentence now cites §0 as the requirement
instead of describing it as something agents happen to do.
I did this work the way the new §0 requires — a worktree at
`<session scratchpad>/wt-workspace-rule`, outside the repo root, branched from `origin/main`,
with the shared checkout left on the branch and with the untracked files I found in it.
## What I verified
Every number in the new §0 table was measured in the shared checkout on 2026-10-05, not
recalled:
| Claim | Command | Value |
|---|---|---|
| shared HEAD is yanked between branches | `git reflog \| grep -c 'checkout: moving'` | **107** |
| it is not sitting on `main` | `git worktree list` | `feat/pm-agent-issue-to-pr` |
| other runs' uncommitted work is in it | `git status --porcelain` | **2** trees: `infra/agent/` (22:04), `platform/pm-agent/` (22:09) — `stat -c %y` |
| its `main` is stale | `git rev-parse main` vs `origin/main` | `4386d14` vs `22c94ef`, **2 behind** |
| worktrees registered / already merged | `git worktree list --porcelain` + `git merge-base --is-ancestor <b> main` | **42** branches, **31** merged |
| nested worktrees are a gate hazard | `grep -rl '\.claude' conformance/tests platform/monitoring/tests` | **9** scripts carry an explicit exclusion |
| the 18× double-count | read `platform/monitoring/tests/run.sh:115-123` | in-tree comment, observed 2026-09-12 |
**A claim of mine was wrong and is corrected in the doc.** I had it recorded that
`git worktree remove` is blocked by the agent sandbox. Provoked rather than assumed: created
a throwaway detached worktree in the scratchpad and removed it — **rc 0**, and
`git worktree list` no longer shows it. §0 therefore tells agents to try it, and the
`rework/<pr-number>` escape hatch is now scoped to *someone else's* stale worktree holding a
branch, which is a real and separate problem (31 of them).
`spec-immutability` is avoidable-not-tolerated: taken from
`decisions/2026-10-01-render-selftest-runs-in-gate.md`, which records
`stage spec-immutability: pass` under the two-mount shim plus `-e SPEC_REPO=/home-network-k3s`.
**Gate — `rc 3`, UNDETERMINED, and I am not calling it green.** Pinned toolbox, from this
worktree, `DEV_KUBECONFIG=/dev/null` and an `ENGINE` shim bind-mounting both the main
checkout's `.git` (at its own host path) and the sibling spec repo read-only with
`-e SPEC_REPO=/home-network-k3s`:
ENGINE=<two-mount shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh' # rc 3
| stage | result |
|---|---|
| spec-immutability | **pass** — it genuinely RAN; the second mount is what makes it run from a worktree |
| approval-policy | pass, but **N/A**: `event='none'`. It cannot see a PR locally, so this green says nothing. See below |
| decisions-log | pass — the three required headings and the frozen-archive pin |
| lint · l0 · render-golden · l1 | pass (L1: 3 offline checks, 0 failures, 0 skips, 0 unwired) |
| gitea-settings | **SKIPPED** — `SETTINGS_API_TOKEN` unset; it exists only in CI |
| conformance (L2/L3) | SKIPPED — `CLUSTER_AVAILABLE!=true`; the decision notes no cluster-needing requirement was touched |
`gitea-settings` is the *only* stage that did not run, and it cannot be made to run from a
workstation, so rc 3 is the ceiling for any local run — not a defect in this branch. The
first gate attempt returned rc 3 for a different and real reason (`HEAD is not ahead of
origin/main`: I had not committed yet, so the touched-requirement set was empty by default
rather than by verification); it was re-run after committing, and the table above is the
post-commit run. §3 now records both expected skips, because it previously named only
`spec-immutability`.
## What I did NOT verify
- **GATE: UNVERIFIED at the time of writing.** The pinned-toolbox result for this branch and
its exit code are reported in the PR, not predicted here. **A local rc 3 is not green** and
will not be described as one.
- **This PR is a sensitive-path PR and will be CI-RED on `approval-policy` until a human acts.**
`CLAUDE.md` is in `infra/gitea/approval-policy.json`'s `sensitive_paths` ("the delivery
contract agents follow"), so the stage requires an approving review from someone who is not
the author, or an `/approve-override <reason>` posted by an administrator **as a PR review
of type "Comment"**, not in the plain comment box. `ci/approval-policy.sh` run locally
reports N/A (it cannot see a PR), so the local gate cannot catch this and a green local run
says nothing about it. **I have not posted an override and must not** — CLAUDE.md §1 forbids
an agent clearing this on its own PR even though `gitea-admin` holds `owner` and could.
- **Nothing here is enforced.** §0 is prose. There is no gate stage that detects work done in
the shared checkout, no check that a worktree is outside the repo root, and no check that a
branch was cut from `origin/main`. Every other rule in CLAUDE.md that agents kept breaking
(`DECISIONS-LOG.md` appends, the three handoff headings) ended up as a gate stage, and that
is the honest follow-up here. I did not write one: the ask was documentation, and an
enforcement stage lands in `ci/**`, which is itself a sensitive path and deserves its own PR.
- **I did not clean up the 31 stale merged worktrees or the 35 nested under
`.claude/worktrees/`.** They are other sessions' workspaces; removing them is an operator
action, and §0 explicitly tells agents not to remove worktrees they did not create.
- **I did not touch the untracked `infra/agent/` and `platform/pm-agent/` trees** sitting in the
shared checkout. They are another run's in-flight work. Whose, and whether they are wanted,
is unknown to me.
- **`docs/plans/ssd-etcd-execution-runbook.md:18` still points at a `.claude/worktrees/` path**
inside the repo. Left as-is deliberately: that runbook is marked COMPLETED/historical and
describes the now-decommissioned dev cluster, so the path is a record of what was done, not
an instruction. It does mean a grep for in-repo worktree paths is not yet clean.
- **No runtime, cluster or Flux behaviour was touched or checked.** This branch changes three
Markdown files; `kubectl` was not run.
@@ -0,0 +1,88 @@
---
date: 2026-10-05
branch: feat/cilium-roll-out-on-config-change
title: Cilium config changes were silently inert — roll the agents when cilium-config changes, which also finally activates the Hubble exporter
---
## What changed
**A merged Cilium config change did nothing, and everything reported success.** The Hubble flow
exporter (PR #50, merged 2026-10-04) wrote its settings into `cilium-config` and never took effect.
| Measured 2026-10-05, over a day after the merge | |
|---|---|
| HelmRelease `cilium` | `Ready=True`, `UpgradeSucceeded`, *"Helm upgrade succeeded for release kube-system/cilium.v6"* |
| `cilium-config` keys | `hubble-export-file-path: stdout`, `hubble-export-allowlist: {"destination_port":["32400"]} {"source_port":["32400"]}` — **all present** |
| cilium agent pods | **all three still the PRE-MERGE pods**, `startTime 2026-10-04T07:11Z` |
| exported flow lines in agent stdout | **0, 0, 0** |
| Flux Kustomizations | all `Ready=True` |
Cause: the chart's `rollOutCiliumPods` defaults to **`false`** (cilium 1.20.1 `values.yaml`: *"Roll
out cilium agent pods automatically when configmap is updated"*), so Helm rewrites the ConfigMap and
leaves the DaemonSet's pod template untouched — and **cilium-agent reads that ConfigMap only at
startup**. The feature was merged, believed delivered, green everywhere, and doing nothing at all.
That is the same class of failure this repo already guards against elsewhere: a green signal sitting
over something that never ran.
- **`platform/cilium/helmrelease.yaml`** (authoritative): `rollOutCiliumPods: true`.
- **`platform/cilium/values.yaml`** (reference mirror) and
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
the same, so a bootstrap render does not differ from what Flux reconciles.
**The cost is real and is the point.** Every future Cilium config change now triggers a cluster-wide
agent rollout instead of waiting for someone to remember a manual restart. That is strictly more
honest — the change applies when merged, or it fails visibly — but it makes config edits disruptive
by default, so they want a quiet moment. Mitigation already in place:
`updateStrategy.rollingUpdate.maxUnavailable: 1` means the DaemonSet will not proceed past an unready
agent, so a bad render costs one node rather than the cluster.
**Merging this is itself one rollout**, because adding the ConfigMap checksum annotation changes the
pod template — and that rollout is what finally activates the Hubble exporter.
## What I verified
- **Every row of the table above**, read from the live HelmRelease status, the live `cilium-config`
ConfigMap, the agent pods' `startTime`, and a grep for `"flow"` in each agent's stdout.
- **The chart value name, default and semantics**, quoted from cilium **v1.20.1**'s own
`values.yaml` — the version actually running here (`quay.io/cilium/cilium:v1.20.1`), not a guess
from another release: `rollOutCiliumPods: false` with the comment *"Roll out cilium agent pods
automatically when configmap is updated."*
- **All three value files carry the key** and both YAML files still parse, with
`spec.values.rollOutCiliumPods` resolving to `true`.
- **The blast-radius mitigation is genuinely present**, not assumed:
`updateStrategy.rollingUpdate.maxUnavailable: 1` is already set in the HelmRelease, with a long
existing comment explaining that a CNI rollout has no healthy-peer fallback.
- **THE MECHANISM IS CONFIRMED BY CONTRAST, not just reasoned.** The cgroup fix (PR #42,
2026-10-03) DID take effect: both the DaemonSet and the running pod mount
`cilium-cgroup hostPath=/sys/fs/cgroup`, matching what the HelmRelease declares. The difference is
exactly the one claimed above — #42 changed the POD TEMPLATE (a volume hostPath), which rolls the
DaemonSet on its own, while #50 changed ONLY the ConfigMap, which does not. It also explains the
agents' `startTime 2026-10-04T07:11Z`: that was #42's rollout, and nothing has rolled them since.
So the inertness is specific to ConfigMap-only changes, which is precisely what this flag fixes.
- **Flux is healthy before this change** — every Kustomization `Ready=True`, so this lands on a clean
graph rather than into an existing cascade.
- **Toolbox gate**: recorded in the PR.
## What I did NOT verify
- **THE OUTCOME, ENTIRELY. Unreconciled.** No agent has rolled, no flow has been exported. This
change is an argument that the rollout will happen, not evidence that it did.
- **Whether `filePath: stdout` actually works** — still the open question from #50, and it cannot be
answered until the agents restart. If the literal is not special-cased, the agent may create a
FILE named `stdout` and nothing reaches Loki: a silent no-op, not a crash. Fallback documented in
#50: keep the chart's default path and add a `loki.source.file` target to Alloy with a hostPath
mount. **Check for flow lines in the agent logs immediately after the rollout.**
- **THE ROLLOUT'S IMPACT ON LAN DNS IS UNMEASURED AND IS THE MAIN RISK.** The node holding the L2
lease for the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP loses them briefly as it cycles.
`maxUnavailable: 1` bounds it to one node at a time, and BPF maps persist across an agent restart,
but I have not measured how long a VIP is unreachable during a lease move, nor whether the two
PiHole replicas cover the gap. Merge it at a quiet moment.
- **Whether enabling this is the operator's preferred trade.** The alternative — leaving it `false`
and restarting by hand — keeps config edits non-disruptive at the cost of them being silently
inert. I judged silent inertness the worse failure, but it is a judgement, not a measurement.
- **Whether anything ELSE in `cilium-config` is currently inert.** I checked the one other recent
Cilium change and it is fine (see below), but I did not audit every key against the running
agents' actual state.
- **`hubble.relay.rollOutPods` / `hubble.ui.rollOutPods`** are left at their `false` defaults. Both
have the same silently-inert property for their own ConfigMaps; not addressed here.
@@ -0,0 +1,90 @@
---
date: 2026-10-05
branch: docs/oauth2-sidecar-scrape-verified
title: The gate-sidecar scrape is verified working on prod — and four places in the tree still said it wasn't
---
## What changed
Documentation only. No manifest, no rendered output, no behaviour.
PR #49 merged as `21c88b0` and the fix works, but four places in the tree still asserted the
*pre-fix* state as current — in the present tense, in capitals, in the files a reader would consult
first. This is the stale-documentation failure mode this repo has been burned by twice already
(`git push origin main` in the runbooks, "append to DECISIONS-LOG.md" in the old handoffs), so:
- **`platform/oauth2-proxy/podmonitor.yaml`** — the banner read *"READ THIS BEFORE BELIEVING THE
TARGETS ARE UP: THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY"* and *"SO THE TARGETS BELOW WILL
READ `up == 0` UNTIL AN INGRESS ALLOWANCE LANDS"*. Both now false, and the first one actively
instructs the reader to disbelieve a correct `up == 1`. Replaced with a `STATUS: THE SCRAPE WORKS`
block carrying the post-fix numbers; the diagnosis is kept below it under an explicit `HISTORY`
heading, because it is why the fix is shaped the way it is. Added the drop fingerprint to
recognise if it regresses — `lastScrapeDuration` pinned at *exactly* the `scrapeTimeout` with
`context deadline exceeded`, versus 1–6 ms when healthy.
- **`catalog/render.py:159`** — *"THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED … Do not
read this flag as 'metrics are working'."* Rewritten to enumerate the three pieces a working
scrape needs and which PR landed each. The warning is **kept, deliberately**: the flag alone
really is insufficient, and "1 without 3" is exactly what caused the 63.7h outage. Only the
"not yet scraped" claim was false.
- **`catalog/render.py:1365`** — the sidecar arg comment, same correction, one line.
- **`platform/oauth2-proxy/README.md`** — its two observability subsections are *dated*
("measured 2026-09-30", "Update 2026-10-01"), so they are honest records rather than lies. But a
reader hits the stale body long before any correction, so a `> SUPERSEDED` pointer now sits
directly under the heading, and an `### Update 2026-10-05` section at the end carries the
before/after table, the TSDB series counts and the alert outcome.
Two substantive errors fixed while there, not just tense:
1. The README's *"To close the gap for an app, three things are needed"* listed **a Service port
and a ServiceMonitor**. Wrong — a PodMonitor targets pod ports directly, which is the whole
reason no Service exposes 44180 and the renderer needed no Service change. Following that
paragraph would have sent someone to edit `catalog/render.py`'s Service rendering for no reason.
2. `podmonitor.yaml` ended by deferring the fix to *"the catalog/renderer, which is in flight on
another branch"* — that branch does not exist and never did; the work happened in PR #49. It now
names `r_monitoring_ingress_policy` directly, and a new `CRAFTY` section explains why crafty is
the one namespace this file cannot help.
## What I verified
- **The fix itself, on prod 2026-10-05, read-only** (this is the evidence the docs now quote):
Flux applied `21c88b0` through the real path — `apps-wave-0` `lastAppliedRevision` at 21:37:01Z,
`apps-wave-1` at 21:37:46Z, in dependency order — and all five `<app>-allow-monitoring-scrape`
CNPs exist, each `ports=['44180']`, `from=[['monitoring']]`.
- **All 6 targets `up == 1`**, `health=up`, `lastError=''`, `lastScrapeDuration` 0.001–0.006s —
against 10.0s and `context deadline exceeded` before.
- **`oauth2_proxy_*` is in the TSDB for these namespaces for the first time:** 5 metric names,
148 series; `sum by (namespace) (oauth2_proxy_requests_total)` = qbittorrent 35072, radarr 23560,
sonarr 1284. (Queried with `count by (__name__)` plus a matcher, not label-values.)
- **Alerts:** all 5 `TargetDown` and 5 of 6 `OAuth2ProxyGateUnobserved` resolved; one series
remains, `OAuth2ProxyGateUnobserved{namespace="crafty"}`, firing.
- **The rendered output is byte-identical.** `catalog/render.py` changed only in comments, so this
branch touches no `apps/` file and no golden. Confirmed by `render-golden` and `l1` both passing
with no regeneration, and by `git status` showing nothing under `apps/`.
- **This branch touches no sensitive path**, unlike PR #49: `platform/oauth2-proxy/**`,
`catalog/**` and `decisions/**` are none of them in `infra/gitea/approval-policy.json`. So it
should merge on a green gate with no human approval — the opposite of the PR it documents, and
the reason the two were kept apart.
- `render.py --selftest`: PASS.
- Toolbox gate: see the handoff's own rule — reported below rather than claimed here.
## What I did NOT verify
- **I did not re-measure the 63.7h figure or the ~22 emails/day rate**; both are carried forward
from the 2026-10-04 measurements, now quoted inside the docs as historical. The 15-day
`query_range` that established "one episode, not flapping" was run once, on 2026-10-04.
- **`oauth2_proxy_requests_total` is still absent for jackett and pihole.** I assert in the README
that this is because the counter is not created until the gate serves its first request. That is
the documented behaviour of a Prometheus counter with no initialisation, and it is consistent
with the other three namespaces having traffic — but I did not force a request through jackett's
gate and watch the series appear. If it is still absent after real use, that is a finding.
- **The auth-churn rule is still unwritten.** The README now says it is "writable rather than
decorative"; that is a claim about the metrics being present, not a rule anyone has authored or
unit-tested. The thing the 2026-09-30 incident actually needed does not exist yet.
- **crafty remains unobserved.** I read its container spec on 2026-10-05 and confirmed no
`--metrics-address`; I did not attempt the fix, and nothing here changes its alert.
- **I did not audit the whole tree for other stale "metrics are not scraped" claims** beyond the
four sites a targeted grep found (`not yet reachable`, `UNTIL AN INGRESS`, `up == 0`,
`not reachable by Prometheus` and neighbours). `runbooks/oauth2-proxy-auth-failures.md` is
described in the README as "the by-hand substitute meanwhile" and I left it alone — the by-hand
log procedure still works and is not wrong, but whether it should now be rewritten against the
new metrics is unexamined.
@@ -0,0 +1,105 @@
---
date: 2026-10-05
branch: fix/plex-resolver-independent-advertise
title: Plex down LAN-wide — the advertised name existed only in PiHole, so clients that bypass it landed on the router's %D cert
---
## What changed
**I took Plex down for the whole LAN.** Reported symptom: every internal client showed a certificate
error with `%D` as the subject name and could not connect.
**Root cause: the advertised connection depended on our own resolver.** `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
happens to use, 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 SOHO router answered the failed lookup with **itself**, and they all landed on the
router's web UI.
| Measured 2026-10-05 | Result |
|---|---|
| `192.168.2.1:443` (router, LAN side) | `C = ZZ, ST = Somewhere, O = %D342e7e6b, CN = %D` |
| `136.51.115.252:443` (router, WAN side) | the same `%D` cert |
| PiHole **pihole-0** | `plex -> 192.168.2.20`, `plex-vip -> 192.168.2.24` ✅ |
| PiHole **pihole-1** | identical ✅ (both behind the `.23` VIP) |
| Gateway `.20:443` cert | `CN = *.home.keithjackson.dev`, valid to 2026-12-12 ✅ |
| `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.** PiHole answered correctly from both
replicas the entire time. And because nothing Plex advertises listens on 443, the clients had plainly
left the Plex path altogether and were talking to the router.
**The fix: go back to `<dashed-ip>.<serverhash>.plex.direct`, which is RESOLVER-INDEPENDENT.** This
is Plex's own mechanism and the reason it exists — `plex.direct` is a public zone that returns the
private address embedded in the label, so *every* resolver (PiHole, the router, 1.1.1.1, a DoH
browser) answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no split-horizon
entry, no Cloudflare record and no certificate work, because the wildcard cert Plex already serves on
32400 covers exactly that name.
- **`catalog/plex.yaml`:** `ADVERTISE_IP` →
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`, with the
resolver-independence requirement written at the top of the block as the first thing to check
before anyone changes this field again.
- **PiHole's `plex-vip` answer is KEPT.** It is harmless, costs nothing, and is useful for reaching
the VIP by name from a LAN shell. It is simply not what Plex advertises.
**Why not keep the nicer hostname.** It cannot be made correct for clients that bypass PiHole: a
public record for it resolves to the WAN address, and with no NAT hairpin on this router a LAN client
sent there fails too. The name is right only for PiHole users and wrong for everyone else, and there
is no configuration of it that is right for both. `plex.direct` is right for both.
**The honest cost, restated rather than buried:** the value embeds both the VIP and the server's
certificate hash as a literal — envsubst cannot dash an IP and nothing in the tree can derive the
hash, so nothing checks that they still agree. That fragility is exactly why the hostname was tried.
It is accepted because an unresolvable name is a *worse* failure: silent, LAN-wide, and it points
users at their router.
## What I verified
Live prod, 2026-10-05, read-only.
- **Every row of the table above**, including the router's `%D` cert on both its LAN and WAN sides —
which is what identifies the router as what the clients were reaching.
- **THE FIX ITSELF, with full TLS validation (no `curl -k`)**: the plex.direct name resolves to
`192.168.2.24` and `/identity` returns **200, TLS verified**. Re-checked today, not carried over
from yesterday's measurement.
- **Both PiHole replicas are identical and correct**, with the same five `address=` lines live in
`/etc/pihole/dnsmasq.conf`, and both IPs present behind the `pihole-dns-tcp`/`-udp` endpoints. I
checked only pihole-0 at first and deliberately went back for pihole-1, because a split-brain pair
would have produced exactly this intermittent-looking symptom.
- **The cluster was never the problem:** all eight LoadBalancer VIPs still assigned, L2
announcements intact (prod-3 holds `.20`/`.23`/`.24`), `wildcard-home` Certificate `Ready=True`,
and PR #51 had reconciled (`customConnections="http://plex-vip.home.keithjackson.dev:32400"` was
live, confirming the bad value was in effect).
- **`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is still set**, so the postStart hook survived
the roll and keeps re-asserting.
- **The Cilium rollout from #50 STILL has not happened** — agents are unchanged since
2026-10-04T07:11, so the Hubble export is inactive and `filePath: stdout` remains unproven.
## What I did NOT verify
- **THE OUTCOME. Unreconciled.** No client has been observed connecting after this change. The
argument is that the advertised name now resolves for every resolver and serves a matching
certificate — both measured — but I have not seen a Plex client complete a connection, and I
cannot see the user's clients at all.
- **THE CLIENT-SIDE RESOLVER PATH IS INFERRED, NOT OBSERVED.** I could not query a public resolver
from here — my attempt returned NXDOMAIN even for a `plex.direct` name that certainly resolves, so
the probe was broken (egress policy or a missing tool) and I discarded it rather than report it.
The chain "client bypasses PiHole → NXDOMAIN → router answers with itself → `%D`" is the only
explanation consistent with all the measurements, but the NXDOMAIN-hijack step in particular is
deduced from the router serving `%D` on its LAN address, not from watching a lookup.
- **Which clients bypass PiHole, or how many.** If in fact every client uses PiHole, then my
explanation is wrong and something else sent them to the router — in which case the next thing to
check is whether the clients were using `plex.home.keithjackson.dev` (port 443, the Gateway) rather
than an advertised connection at all.
- **Whether plex.tv will re-publish promptly** after the value changes, and whether stale connection
lists are cached on clients. A client holding the old list may keep failing until it refreshes.
- **Mixed content.** The previous `http://` value would also have been blocked by the browser for
anyone using `https://app.plex.tv`; that is fixed incidentally by returning to `https`, but I did
not test it.
- **The Remote badge — the symptom that began this whole sequence — remains unconfirmed.**
`LanNetworksBandwidth` is set, but Plex still tags traffic `Allowed Network (WAN)`. Unchanged and
still unexplained.
- **`ManualPortMappingMode="1"` with an absent port** is still there, untouched.
+2 -1
View File
@@ -26,7 +26,8 @@ The fix is the obvious one for concurrent appenders: **stop sharing a file**. Ea
handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is
nothing for git to merge and nothing to conflict. This scales to any number of parallel
branches with zero coordination — which is the point, since the agents are deliberately
isolated from each other in separate worktrees and cannot coordinate.
isolated from each other in separate worktrees (required by `CLAUDE.md` §0) and cannot
coordinate.
There is deliberately **no index file** and no counter. An index would be a shared
mutable file — exactly the thing that just failed — and a sequence number would have
@@ -306,19 +306,27 @@ node_baseline_dns_servers:
# k3s_base_domain, below). It reaches the issuer through the NODE resolver, i.e. through
# these PUBLIC upstreams — not through CoreDNS's split-horizon answer.
#
# THIS USED TO BE BROKEN, AND THE 2026-09-04 SUBDOMAIN SPLIT FIXED IT. While prod was
# authored to serve dev's hostnames, a prod node resolving keycloak.home.keithjackson.dev
# publicly got DEV's Gateway VIP (192.168.1.53) — the wrong cluster, on the wrong LAN, so
# kubectl OIDC SSO on prod could not work at all. Now prod's issuer is
# keycloak.prod.home.keithjackson.dev, and the public record for that name is published by
# PROD's own external-dns pointing at PROD's Gateway VIP (192.168.2.20). The public answer
# and the split-horizon answer therefore AGREE, and no /etc/hosts pin is needed.
# THE PUBLIC ANSWER IS NOW THE WAN, SO THE ISSUER IS PINNED (2026-10-03). Since
# platform/gateway/gateway.yaml set an external-dns `target`, every public name is a CNAME
# to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from the
# LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
# k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18). The apiserver would then
# be unable to fetch its issuer's discovery/JWKS, so kubectl OIDC SSO would fail on the
# next k3s restart or Keycloak key rotation. node_baseline_hosts_pins (below) pins the
# issuer host to the Gateway VIP in /etc/hosts, which glibc consults before these
# resolvers. (History: before the 2026-09-04 subdomain split the public answer was DEV's
# VIP; after it, the public answer was prod's own VIP and no pin was needed.)
#
# STILL TO VERIFY on prod bring-up (PROD-PROVISIONING.md §10): this holds only once prod's
# external-dns has actually published the record. Until then the issuer does not resolve
# from the nodes and kubectl SSO fails — the cert-based admin kubeconfig is the break-glass
# path, and it always works. Ordering, not a design gap.
# The cert-based admin kubeconfig never depends on any of this. It is the break-glass path.
node_baseline_dns_search: []
# /etc/hosts pins: node-process names that must reach the in-cluster Gateway directly rather
# than via the public (WAN, non-hairpinned) answer. See "THE PUBLIC ANSWER IS NOW THE WAN" above.
# [parity] the IP MUST equal GATEWAY_VIP in clusters/prod/kustomization.yaml (192.168.2.20),
# and the name MUST follow k3s_oidc_issuer_url's host (roles/k3s_server/defaults/main.yml).
node_baseline_hosts_pins:
- ip: "192.168.2.20"
names:
- "keycloak.{{ k3s_base_domain }}"
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
@@ -39,23 +39,46 @@ bpf:
masquerade: true
# Flow observability / isolation audit (ADR-0002).
# Roll the agents when cilium-config changes. Kept EQUAL to the Flux values
# (platform/cilium/helmrelease.yaml), which carry the full rationale: the chart default of false
# leaves the pod template untouched on a config change, and cilium-agent reads that ConfigMap only
# at startup, so the change is silently inert until someone restarts the DaemonSet by hand.
rollOutCiliumPods: true
hubble:
enabled: true
relay:
enabled: true
ui:
enabled: true
# Static flow export -> cilium-agent stdout. Kept EQUAL to the Flux values
# (platform/cilium/helmrelease.yaml), which carry the full rationale: it records the PRE-SNAT
# client address for Plex's raw tcp/32400, which Plex itself cannot log because every
# LoadBalancer Service here is externalTrafficPolicy: Cluster and the VIP lease holder SNATs the
# client. Present here so a BOOTSTRAP render does not differ from what Flux reconciles -- a
# mismatch would show up as a permanent Helm diff and an avoidable agent rollout. Scoped by PORT,
# not namespace: the flow carrying the real client address does not match a namespace filter.
export:
static:
enabled: true
filePath: stdout
allowList:
- '{"destination_port":["32400"]}'
- '{"source_port":["32400"]}'
# Ingress VIP announcement on the NAT LAN. IP pool / CiliumL2AnnouncementPolicy
# (from the reserved 10.73.0.240-.250 range) is authored by WS-NET in Phase 2.
l2announcements:
enabled: true
# k3s ships its own cgroup mount; point Cilium at it instead of auto-mounting.
# Point Cilium at the host's real cgroup2 root (/sys/fs/cgroup, mounted by systemd) instead of
# auto-mounting. NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so the agent mounted its
# OWN namespaced cgroup and socket-LB covered only itself — host processes (the apiserver) lost
# Service/LB translation. [parity] platform/cilium/helmrelease.yaml explains (2026-10-03).
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
@@ -242,3 +242,9 @@ node_baseline_dns_search:
node_baseline_dns_options:
- timeout:2
- attempts:2
# Static /etc/hosts entries (tags: dns, hosts), as a list of {ip, names}. EMPTY by default:
# dev needs none. Prod pins its OIDC issuer host to the Gateway VIP because the public
# answer for it is the non-hairpinned WAN IP (inventory/group_vars/k3s_prod/vars.yml).
# Written as ONE marked block, so removing an entry here removes it from the node, and
# an empty list removes the block entirely.
node_baseline_hosts_pins: []
@@ -1090,3 +1090,22 @@
{% endif %}
when: node_baseline_dns_mechanism == 'resolv-conf-file'
tags: [dns]
# /etc/hosts pins — tag `dns` (and `hosts` on its own). Some node processes must reach a
# public hostname at the in-cluster Gateway, not at its public answer. On prod, the
# kube-apiserver's OIDC issuer resolves publicly to the WAN IP, which the router does not
# hairpin. glibc reads /etc/hosts before any nameserver (nsswitch `files dns`), so a pin wins
# regardless of which resolver mechanism above is in effect. One marked block: entries
# dropped from node_baseline_hosts_pins disappear from the node, and an empty list
# removes the block.
- name: Pin node-process hostnames in /etc/hosts
ansible.builtin.blockinfile:
path: /etc/hosts
marker: "# {mark} node_baseline hosts pins (Ansible, tags: hosts)"
block: |
{% for pin in node_baseline_hosts_pins %}
{{ pin.ip }} {{ pin.names | join(' ') }}
{% endfor %}
state: "{{ 'present' if node_baseline_hosts_pins | length > 0 else 'absent' }}"
backup: true
tags: [dns, hosts]
+63 -1
View File
@@ -52,7 +52,7 @@ All four steps are complete and each was verified, not assumed:
| 1. Runner registered | ✅ `incluster-dind` online. Root cause of its absence was a stale `/data/.runner` identity; clearing the PVC forced re-registration |
| 2. Gate observed green on a real PR | ✅ `gate / gate (pull_request)` ~1m35s |
| 3. `enable_status_check: true` | ✅ context `gate / gate (pull_request)` — that one ONLY |
| 4. Collaborator + approvals | ✅ `jakman85` is owner; `required_approvals: 1` |
| 4. Collaborator + approvals | ✅ `jakman85` is owner. `required_approvals` was raised to 1 here and **lowered back to 0 on 2026-10-01** — see §6, it was a lock-out |
Direct push to `main` was **provoked and rejected** (`pre-receive hook declined`) as
`gitea-admin`, a site admin — so push protection is not merely declared, it holds for the
@@ -124,6 +124,66 @@ the stage goes **green on a real PR**, and only then set `REQUIRE_GITEA_SETTINGS
**Changing the declaration is how you change policy.** If the live state is what you now want,
edit this JSON in the same commit — deliberately, in review — rather than letting the two drift.
### 6. `allow-admin-self-merge.sh` — undoing the lock-out step 4 created (2026-10-01)
Step 4 above raised `required_approvals` to 1. That was a **lock-out, not a protection**, and the
note at the bottom of `require-gate.sh` had already said why: *Gitea will not let an author approve
their own PR.* With `jakman85` the only collaborator, there was no account in existence that could
approve a PR `jakman85` opened.
Measured 2026-10-01 — **PR #35**: `gate / gate (pull_request)` **success in 1m41s**, reviews `[]`,
`required_approvals: 1`, `block_admin_merge_override: true`. Unmergeable, permanently, with no
action available to its author. The same was true of any PR fixing it.
It was also never the documented design. `ci/approval-policy.sh`'s header and `ci/gate.sh:194`
both already describe the model as *"`required_approvals` drops to 0 and the GATE decides"*, and
`decisions/2026-09-30-flux-deploy-notification-inhibited.md` flagged the 1-vs-0 disagreement as
unresolved and not that session's to settle. So this is a correction toward the stated intent, not
a weakening away from it.
./infra/gitea/allow-admin-self-merge.sh # required_approvals -> 0 (read from the declaration)
**What stays switched on, and why that is the point.** The other way to unblock an owner is
`block_admin_merge_override: false`, and it would have been the wrong one — that re-opens exactly
what §5 above closed, letting the one account that merges bypass the gate itself. This script
touches the approval **count** only and *verifies* afterwards that `block_admin_merge_override` and
`enable_status_check` are both still `true`, failing loudly if the PATCH disturbed either:
| | before | after |
|---|---|---|
| `required_approvals` | 1 | **0** |
| `block_admin_merge_override` | true | true (asserted) |
| `enable_status_check` | true | true (asserted) |
So a **red gate is still unmergeable by everyone, administrators included**. What changed is that
a *green* one no longer needs a signature that cannot exist.
**Security-relevant changes are still reviewed** — by `ci/approval-policy.sh` against
`approval-policy.json` `sensitive_paths`, not by this field. An administrator waives that review
only by posting `/approve-override <reason>` on the PR: reason mandatory, permission re-checked
live, invalidated by a new commit, and recorded by account in the gate summary.
> **Post the override as a PR *review* (type "Comment"), not in the plain comment box** — until
> the CI token is re-minted. `SETTINGS_API_TOKEN` is `[read:repository, read:admin]`, and
> `/issues/{n}/comments` needs Gitea's *issue* scope, so Gitea answers with an error **object**
> instead of an array and the override is invisible. Measured on PR #38 (2026-10-02): a correct,
> fresh, well-formed override in the comment box produced `approval-policy: FAIL` reading as "no
> approving review" — a permission failure disguised as a missing one. `/pulls/{n}/reviews` is
> under `read:repository` and worked in the same run. The stage now reads **both** sources, names
> which one it used, and says plainly when the comments API is unreadable. `set-ci-token.sh`
> requests `read:issue` as of 2026-10-02, so **re-run it** and the comment box works too — token
> scopes cannot be widened in place. Note that the
**agent account `gitea-admin` also reports permission `owner`**, so it can clear its own PRs that
way; that is an accepted consequence of authorising by permission alone, and the route to
narrowing it (an `override_admins` allowlist, already honoured by the code) is written down in
`approval-policy.json`.
**Sequencing.** `ci/gitea-settings.sh` compares live against the declaration *from the branch under
test*, and `REQUIRE_GITEA_SETTINGS=true`. So merge any other open PRs **before** running this, then
run it, then re-run the gate on the branch carrying the new declaration. Any PR left open across
the change declares 1 against a live 0 and goes **red until rebased** — the detector working, not
a fault. The script's header spells the order out.
## `conformance-trigger-kubeconfig.sh` — the narrow L2/L3 trigger credential
Implements `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 (= §5 step 7): mint a
@@ -204,6 +264,8 @@ it. The name matters — see the note under the behaviour table above.)
2. **Watch the gate actually report on a real PR**, and note the context name it produced.
3. **Only then set `enable_status_check: true`** with that exact context.
4. Add the human reviewer as a repo collaborator, then raise `required_approvals` to 1.
— **Step 4 was REVERSED on 2026-10-01 (§6).** It assumed a reviewer distinct from the author
would exist. One never did, so this step produced a lock-out. Do not re-apply it.
> **Do not do step 3 before step 1.** A required status check with no runner leaves every
> PR permanently pending — including the PR that would fix it. That half is measured: PR #1
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Let the sole administrator merge their own green PR: required_approvals 1 -> 0 on `main`.
#
# WHY. `required_approvals: 1` plus Gitea's refusal to let an author approve their own pull
# request plus exactly ONE collaborator (`jakman85`, permission `owner`) is a lock-out, not a
# policy. Every PR the operator opened was permanently unmergeable, including the PR that would
# fix it. Measured 2026-10-01: PR #35 — `gate / gate (pull_request)` success in 1m41s, zero
# reviews, `required_approvals: 1`, no account in existence able to satisfy it.
#
# The approvals field was also never what the design said it was. `ci/approval-policy.sh`'s
# header and `ci/gate.sh:194` both already described the model as "required_approvals: 0 and the
# GATE decides", and `require-gate.sh`'s own closing note named this exact failure and this exact
# remedy. decisions/2026-09-30-flux-deploy-notification-inhibited.md flagged the 1-vs-0
# disagreement as unresolved. This resolves it at 0.
#
# WHAT THIS DOES *NOT* DO, and the distinction is the whole point.
#
# * It does NOT touch `block_admin_merge_override`, which stays TRUE. The required status check
# still binds ABSOLUTELY — a red gate is unmergeable by everyone, administrators included.
# Setting `block_admin_merge_override: false` would have been the other way to unblock an
# owner, and it would have re-opened precisely what block-admin-override.sh was written to
# close: PRs #5-#9 merged with zero approvals while the settings read as enforced.
# * It does NOT stop reviewing security-relevant changes. Those are gated by
# ci/approval-policy.sh against infra/gitea/approval-policy.json `sensitive_paths`, which an
# administrator can waive only with an explicit, attributed `/approve-override <reason>`
# comment on the PR.
#
# ORDER MATTERS — READ THIS BEFORE RUNNING IT.
#
# ci/gitea-settings.sh fails the merge gate on ANY divergence between the live rule and
# infra/gitea/expected-branch-protection.json, and `gate.yml` sets REQUIRE_GITEA_SETTINGS=true.
# The declaration in git now says 0. So between running this and merging that declaration, the
# live value and the declared value must be compared from a branch that CARRIES the new
# declaration — otherwise the stage goes red, correctly, for drift.
#
# 1. MERGE any other open PRs FIRST, while approvals still work for them (their branches
# declare 1, and a PR you did not author can still be approved normally). Skipping this
# only costs a rebase later; it does not break anything.
# 2. Run this script.
# 3. Re-run the gate on the branch that carries the new declaration. `gitea-settings` goes
# green because live 0 == declared 0.
# 4. Merge that branch.
# 5. Any PR still open from before step 2 now declares 1 against a live 0 and will be RED
# until it is rebased onto the new main. That is the detector doing its job, not a fault.
#
# Recoverable: `required_approvals` can be PATCHed back to 1 by an owner at any time. If you do,
# change expected-branch-protection.json in the same breath or every PR goes red on drift.
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
DECL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/expected-branch-protection.json"
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
[ -f "$DECL" ] || { echo "declaration missing: ${DECL}" >&2; exit 1; }
# Take the target from the DECLARATION, not from a literal here. If the two could disagree,
# this script would be one more thing re-asserting a value nothing checks — which is the
# failure mode the whole of infra/gitea/ exists to stop.
WANT="$(jq -r '.protection.required_approvals' "$DECL")"
[[ "$WANT" =~ ^[0-9]+$ ]] || { echo "declaration has no numeric required_approvals" >&2; exit 1; }
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
[ -n "$U" ] && [ -n "$P" ] || { echo "no stored credential for $HOST" >&2; exit 1; }
echo "authenticating as '${U}' (secret not shown)"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
B="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main"
# Read the RULE OBJECT, never /branches/main: the branch view does not expose
# block_admin_merge_override and showed all-green for hours while the rule was overridable.
echo "==> before"
api "$B" | jq -r '{required_approvals, block_admin_merge_override, enable_status_check, status_check_contexts}'
echo "==> setting required_approvals = ${WANT} (declared in expected-branch-protection.json)"
api -X PATCH -H 'Content-Type: application/json' \
-d "{\"required_approvals\": ${WANT}}" "$B" >/dev/null
echo "==> after"
AFTER_JSON="$(api "$B")"
jq -r '{required_approvals, block_admin_merge_override, enable_status_check, status_check_contexts}' <<<"$AFTER_JSON"
GOT="$(jq -r '.required_approvals' <<<"$AFTER_JSON")"
ADMIN_BLOCK="$(jq -r '.block_admin_merge_override' <<<"$AFTER_JSON")"
STATUS="$(jq -r '.enable_status_check' <<<"$AFTER_JSON")"
fail=0
[ "$GOT" = "$WANT" ] || { echo "PATCH did not take: required_approvals is ${GOT}, wanted ${WANT}" >&2; fail=1; }
# These two are what keeps this change narrow. If the PATCH disturbed either, the gate has
# stopped binding and that must be loud, not discovered later.
[ "$ADMIN_BLOCK" = true ] || { echo "block_admin_merge_override is ${ADMIN_BLOCK}, MUST be true — the gate is now bypassable" >&2; fail=1; }
[ "$STATUS" = true ] || { echo "enable_status_check is ${STATUS}, MUST be true — the gate is no longer required" >&2; fail=1; }
[ "$fail" -eq 0 ] || { echo "do NOT record this as applied." >&2; exit 1; }
cat <<'NOTE'
NOW PROVE IT -- the setting is not the evidence. Two things to see, not assume:
1. An APPROVAL IS NO LONGER NEEDED. On your own PR with a green gate, the merge button is
live with zero reviews on it.
2. THE GATE STILL BINDS. On a PR whose gate is RED or still pending, Gitea must REFUSE the
merge -- for you, as owner. If it lets you through, block_admin_merge_override is not
doing its job and this change has traded a lock-out for a bypass.
Then confirm the detector agrees: run ci/gate.sh from a branch carrying the new
expected-branch-protection.json and check that `gitea-settings` is green rather than DRIFT.
NOTE
+14 -1
View File
@@ -1,6 +1,6 @@
{
"_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.",
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author. Everything else merges on a green gate alone.",
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author, OR an admin override (see .override). Everything else merges on a green gate alone.",
"_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.",
"sensitive_paths": [
"ci/**",
@@ -24,5 +24,18 @@
"platform/kyverno/**": "the admission policy engine",
".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority",
"CLAUDE.md": "the delivery contract agents follow"
},
"override": {
"_why": "This repository has ONE human. Gitea refuses to let an author approve their own pull request, so 'a non-author APPROVED review' is a requirement the sole administrator can never satisfy -- every sensitive-path PR they open was permanently unmergeable, including the PR that would fix it. Measured 2026-10-01: PR #35 green gate, zero reviews, unmergeable. An escape hatch that the administrator can actually reach is therefore REQUIRED for the repository to function; the design question is only whether it leaves a record. This one does.",
"enabled": true,
"comment_directive": "/approve-override",
"requires_reason": true,
"authorized_permissions": ["admin", "owner"],
"invalidated_by_new_commits": true,
"_how": "Post the directive at the START of a line, followed by a reason: '/approve-override <why>'. ci/approval-policy.sh resolves the poster's LIVE repo permission via /collaborators/{login}/permission and accepts the override only if it is in authorized_permissions. The overrider, the source, the timestamp and the reason are printed in the gate log and the job summary.",
"_where_to_post_it": "EITHER a PR REVIEW body (type 'Comment') OR the ordinary PR comment box. Prefer the REVIEW: measured 2026-10-02 on PR #38, a well-formed override in the comment box was INVISIBLE to CI, because /issues/{n}/comments sits behind Gitea's *issue* token scope and SETTINGS_API_TOKEN is minted [read:repository, read:admin]. Gitea returned an error object rather than an array, and the stage reported 'no approving review' -- a permission problem wearing the costume of a missing override. /pulls/{n}/reviews is under read:repository and was readable in the same run, so a review body is the route that works on today's token. infra/gitea/set-ci-token.sh now also requests read:issue, which makes the comment box work -- but only after that script is RE-RUN, since an existing token's scopes cannot be widened in place. The stage accepts both sources and names which one it used, so neither route is load-bearing alone.",
"_self_override_is_allowed": "The author may override their own PR if they hold an authorized permission. That is the entire point -- with one human there is no one else -- but it means the 'a change to the gate cannot be gated by the gate' protection is, for an administrator, advisory rather than structural. It is traded for an auditable record: the waiver is an explicit act, attributed, with a stated reason, in the PR's comment history and the gate output.",
"_known_consequence_agent_accounts": "ACCEPTED, EYES OPEN (operator decision, 2026-10-01). Authorisation is by live permission ALONE, with no allowlist of human accounts. The agent account `gitea-admin` reports permission `owner` on this repo (measured 2026-10-01), so an AGENT can clear the sensitive-path review on its own PR by posting the directive. Agents author most PRs here, so this is the broad case, not the edge case. To narrow it later without changing any logic, add an `override_admins` allowlist here and require membership in addition to the permission check -- ci/approval-policy.sh reads both from this file.",
"_staleness": "An override is dropped unless its comment is NEWER than the PR head commit, mirroring Gitea's own dismiss_stale_approvals. An override earned at commit A therefore does not silently cover commit B; push again and you must override again."
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
{
"_comment": "DECLARED desired state of Gitea branch protection on main. ci/gitea-settings.sh fails the merge gate when the LIVE Gitea diverges from this. Gitea settings live in Gitea database - invisible to Flux, unreadable by every other check - so this file plus that check are the only drift detection that exists for them.",
"_generated": "2026-09-29 from the live rule, after the session that found protection OFF and zero runners for a month",
"_amended": "2026-10-01: required_approvals 1 -> 0. See _why_required_approvals_is_zero.",
"_why_required_approvals_is_zero": "The GATE is the approver (operator intent, 2026-09-29). required_approvals: 1 was never consistent with that intent: ci/approval-policy.sh's header and ci/gate.sh:194 both already described the model as 'required_approvals: 0 and the GATE decides', and infra/gitea/require-gate.sh's own closing note named the consequence of 1 -- 'Gitea will not let an author approve their own PR'. With exactly ONE collaborator (jakman85, owner), that made every PR the operator opened permanently unmergeable. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, no path to a merge. decisions/2026-09-30-flux-deploy-notification-inhibited.md had already flagged the 1-vs-0 disagreement as unresolved; this settles it at 0.",
"_what_still_holds_at_zero": "This drops the APPROVAL COUNT only. block_admin_merge_override stays TRUE and enable_status_check stays TRUE, so the required gate context still binds absolutely -- nobody, administrator included, can merge a red gate. That distinction is the whole reason this is the chosen fix rather than block_admin_merge_override: false, which would have handed admins a bypass over the gate itself and re-opened what infra/gitea/block-admin-override.sh was written to close (PRs #5-#9, merged with zero approvals). Architecturally and security-relevant paths are still reviewed, by ci/approval-policy.sh rather than by this field.",
"branch": "main",
"runners_online_min": 1,
"protection": {
@@ -11,7 +14,7 @@
"status_check_contexts": [
"gate / gate (pull_request)"
],
"required_approvals": 1,
"required_approvals": 0,
"enable_approvals_whitelist": false,
"block_admin_merge_override": true,
"block_on_rejected_reviews": true,
+16 -6
View File
@@ -34,7 +34,7 @@ jq -n '{
enable_push: false,
enable_status_check: true,
status_check_contexts: ["gate / gate (pull_request)"],
required_approvals: 1,
required_approvals: 0,
block_on_rejected_reviews: true,
dismiss_stale_approvals: true,
block_on_outdated_branch: false,
@@ -48,11 +48,21 @@ api "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branches/main" \
cat <<'NOTE'
NOTE ON required_approvals: 1 -- Gitea will not let an author approve their own PR.
* Agent-opened PRs (author gitea-admin) are approved by jakman85. Fine.
* PRs YOU open as jakman85 need gitea-admin to approve. If that is awkward, either open
agent work as gitea-admin and review as jakman85, or set required_approvals back to 0
and rely on the status check alone.
NOTE ON required_approvals: 0 -- CHANGED FROM 1 ON 2026-10-01, and this script is the reason
the change had to land here too: re-running it with 1 would silently re-impose the lock-out.
The note that used to sit here called the problem and its own fix: "Gitea will not let an
author approve their own PR ... if that is awkward ... set required_approvals back to 0 and
rely on the status check alone." It was awkward. With jakman85 the ONLY collaborator, every PR
the operator opened was unmergeable -- measured on PR #35, gate green in 1m41s, zero reviews,
nothing that could ever satisfy the rule. So: 0, and the status check alone, as written.
What did NOT change, deliberately:
* block_admin_merge_override stays TRUE. This drops the approval COUNT; it does not hand
anyone a bypass over the gate. A red gate is still unmergeable by everybody.
* Architecturally/security-relevant paths are still human-reviewed -- by ci/approval-policy.sh
(sensitive_paths in infra/gitea/approval-policy.json), which an administrator can waive
only by posting an explicit, attributed `/approve-override <reason>` comment.
Then prove it, rather than trusting the API:
git commit --allow-empty -m probe && git push origin main # EXPECT: rejected
+18 -4
View File
@@ -5,9 +5,23 @@
# The token value is NEVER printed. It goes from the token API into the secrets API and
# nowhere else -- not to stdout, not to a file, not into an agent transcript.
#
# Scope: `read:repository` + `read:admin` are what the check needs (the rule object, and the
# runner list). Gitea token scopes are coarse; grant the least that works on your version and
# confirm the check goes green rather than assuming the scope name.
# Scope: `read:repository` + `read:admin` are what ci/gitea-settings.sh needs (the rule object,
# and the runner list). `read:issue` is for ci/approval-policy.sh, and it was MISSING — see below.
# Gitea token scopes are coarse; grant the least that works on your version and confirm the check
# goes green rather than assuming the scope name.
#
# WHY read:issue IS HERE (added 2026-10-02, measured on PR #38). ci/approval-policy.sh reads the
# PR's comments to find an admin `/approve-override`. /issues/{n}/comments sits behind the *issue*
# scope, which this script did not request — so Gitea answered with an error OBJECT instead of an
# array, and a perfectly well-formed override posted in the PR comment box was invisible to CI.
# The failure did not look like a permission problem from the outside; it looked like the override
# had not been posted. `/pulls/{n}/files` and `/pulls/{n}/reviews` are under read:repository, which
# is why every other part of the same stage worked and this one did not.
#
# The stage now accepts an override from a REVIEW body as well, so it still functions on a token
# WITHOUT read:issue. This scope is what makes the ordinary comment box work too — the thing
# anybody would reach for first. **Re-run this script to pick it up; an existing token's scopes
# cannot be widened in place.**
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
# NOT GITEA_* — Gitea reserves that prefix for secret names and returns HTTP 400
@@ -21,7 +35,7 @@ basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
echo "==> minting token '${NAME}' as ${U}"
TOKEN="$(basic -X POST -H 'Content-Type: application/json' \
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\"]}" \
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\",\"read:issue\"]}" \
"https://${HOST}/api/v1/users/${U}/tokens" | jq -r '.sha1 // empty')"
[ -n "$TOKEN" ] || { echo "token creation failed (check scopes for your Gitea version)" >&2; exit 1; }
echo "==> got a ${#TOKEN}-character token (value not shown)"
+109 -5
View File
@@ -101,20 +101,124 @@ spec:
enabled: true
ui:
enabled: true # exposure gated behind oauth2-proxy forward-auth later (ADR-0014)
# --- STATIC FLOW EXPORT: persist the PRE-SNAT client address for Plex (2026-10-04) ---
# [parity] MUST equal Ansible: hubble.export.static.{enabled,filePath,allowList}
#
# WHY THIS EXISTS. Plex's own log cannot show who connected. Every LoadBalancer Service in
# this cluster is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the
# VIP SNATs the client before forwarding to the backend pod. Measured 2026-10-04: Plex had
# logged 3,214 requests from `10.0.1.x` (the lease holder k3s-prod-3's own address, Cilium
# identity 1 = reserved:host) and ZERO from any 192.168.x address, ever. No X-Forwarded-For
# either -- it serves raw tcp/32400, not HTTP through the Gateway.
#
# THE ADDRESS IS NOT LOST, ONLY UNRECORDED. Cilium observes the flow at ingress, BEFORE its
# own translation, so Hubble already has the true source. Measured on the lease holder:
# 192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
# 10.0.1.78 -> 10.0.0.135 (the same traffic, post-SNAT -- what Plex sees)
# What was missing is RETENTION: the ring buffer was 4095/4095 flows at ~303 flows/s, i.e.
# about THIRTEEN SECONDS of history, with hubble metrics disabled and no exporter configured.
#
# WHY NOT FIX THE SNAT INSTEAD. `externalTrafficPolicy: Local` would preserve the address in
# Plex's own log, but cilium 1.20's L2 announcer is explicitly incompatible with it -- upstream
# docs for this exact version: "incompatible with the externalTrafficPolicy: Local on services
# as it may cause service IPs to be announced on nodes without pods causing traffic drops",
# and "L2 announcements isn't currently aware of this setting". Live proof that this is not
# theoretical: the VIP is announced by k3s-prod-3 while the pod runs on k3s-prod-1, so the
# flip would black-hole tcp/32400 immediately. Making it safe would mean pinning BOTH the pod
# and the announcement to one node and giving up Plex's ability to reschedule -- a real
# availability loss to buy a log field. (It is the same trap platform/crafty/service-l4.yaml
# documents from an 8-day Minecraft outage.) DSR was also rejected: routing-mode here is
# vxlan, not native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO
# router's stateful NAT on the remote path.
#
# `filePath: stdout` RATHER THAN A FILE, deliberately. Grafana Alloy already runs as a
# DaemonSet tailing every pod's logs into Loki (platform/monitoring/alloy.yaml), so stdout
# reaches Loki with NO new hostPath mount, no file-rotation handling and no Alloy change.
# Upstream documents it: "To get Hubble flows directly exported to the logs instead of written
# to a rotated file, `stdout` can be defined as `hubble-export-file-path`." fileMaxSizeMb /
# fileMaxBackups are therefore left at chart defaults and do not apply.
# Query: {namespace="kube-system", app="cilium"} |= "32400"
#
# SCOPED BY PORT, NOT BY NAMESPACE -- and that is a measured decision, not a preference. A
# `--to-namespace plex` query returned ONLY the post-SNAT flows; the flow carrying the real
# client address did NOT match it, because at the point of observation the destination
# identity is not yet resolved to the plex pod. Filtering by namespace would therefore drop
# exactly the record this change exists to capture. Port it is.
#
# Both directions are listed so the return path is captured too. Volume is small (a few
# hundred to a few thousand lines a day); if it ever becomes noisy, narrow it with
# `fieldMask` rather than by removing a filter.
export:
static:
enabled: true
filePath: stdout
allowList:
- '{"destination_port":["32400"]}'
- '{"source_port":["32400"]}'
# --- L2 announcement for the ingress VIP on the flat LAN (docs/09, OQ-1 resolved) ---
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
l2announcements:
enabled: true
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
# part of the working CNI config on k3s: reconciling without it lets Cilium try to auto-mount
# cgroupv2 and break on adopt. Kept in parity for a safe Phase-2 adoption.
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
#
# FIXED 2026-10-03 — hostRoot WAS /run/cilium/cgroupv2, AND THAT SILENTLY BROKE SOCKET-LB FOR
# EVERY HOST PROCESS. With autoMount disabled, the chart only bind-mounts whatever already exists
# at hostRoot ("it's expected that users have mounted cgroup2 filesystem at the specified
# cgroup.hostRoot" — cilium 1.20.1 values.yaml). Nothing ever mounted cgroup2 at
# /run/cilium/cgroupv2 on these nodes (it is an empty DirectoryOrCreate hostPath), so the agent
# mounted one ITSELF from inside its own private cgroup namespace. That mount's root is the
# agent container's OWN leaf cgroup, so cil_sock4_connect & co. were attached there and covered
# only the agent. Every other host-netns process (the k3s apiserver in
# /system.slice/k3s.service, curl over SSH) was untranslated: Service ClusterIPs timed out from
# every node, and the node holding the Gateway's L2 lease could not reach the Gateway VIP at all
# ("no route to host"). That is why k3s-prod-1's apiserver logged `oidc: authenticator not
# initialized` from 2026-09-12 on — kubectl SSO through the kube-vip VIP failed outright.
# Measured on k3s-prod-1 before this change: TCP connect to 192.168.2.20:443, 10.43.225.79:443
# and 10.43.59.141:8080 SUCCEEDED from inside the agent container (the covered cgroup) and
# FAILED from the host, same node, same netns. Full write-up:
# decisions/2026-10-03-cilium-cgroup-hostroot.md.
#
# /sys/fs/cgroup IS a cgroup2 mount in the host's root cgroup namespace on every node (verified
# with findmnt on all three), so bind-mounting it gives the agent the real host root. autoMount
# stays off because the mount it would create already exists.
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
# --- Roll the agents when their ConfigMap changes (2026-10-05) ---
# [parity] MUST equal Ansible: rollOutCiliumPods: true
#
# WHY: a merged Cilium config change DID NOTHING. The chart default is `false`
# (cilium 1.20.1 values.yaml: "Roll out cilium agent pods automatically when configmap is
# updated. rollOutCiliumPods: false"), so Helm rewrites `cilium-config` and leaves the
# DaemonSet's pod template untouched — and cilium-agent reads that ConfigMap only at STARTUP.
# The result is a silently inert change: Flux green, HelmRelease `UpgradeSucceeded`, the new
# keys visibly present in the ConfigMap, and the running agents still on the old settings
# until a human remembers to restart them.
#
# MEASURED, and it is why this exists: the Hubble flow exporter merged 2026-10-04 wrote
# `hubble-export-file-path: stdout` and its allowlist into `cilium-config`, the HelmRelease
# reported `Helm upgrade succeeded ... cilium.v6`, and over a DAY later all three agents were
# still the pre-merge pods (startTime 2026-10-04T07:11Z) with ZERO exported flow lines. The
# feature was merged, believed delivered, and doing nothing at all. That is the same class of
# failure this repo already documents elsewhere — a green signal over something that never ran.
#
# THE COST IS REAL AND IS THE POINT: every future Cilium config change now triggers a
# cluster-wide agent rollout instead of waiting for a manual restart. That is strictly more
# honest — the change applies when it is merged, or it fails visibly — but it means config
# edits are disruptive by default, so they want a quiet moment. One node at a time:
# `updateStrategy.rollingUpdate.maxUnavailable: 1` below means the DaemonSet will not proceed
# past an unready agent, so a bad render costs one node rather than the cluster. Note that the
# node holding the L2 lease for the Gateway, PiHole-DNS and Plex VIPs loses them briefly as it
# cycles, so a rollout can blip LAN DNS.
#
# ENABLING THIS IS ITSELF ONE ROLLOUT: adding the ConfigMap checksum annotation changes the pod
# template, so merging this rolls the agents once — which is what finally activates the Hubble
# exporter that has been inert since 2026-10-04.
rollOutCiliumPods: true
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
# maxUnavailable: 2) — NOT a local choice, and on a 3-node cluster that lets a single bad
+25 -2
View File
@@ -32,20 +32,43 @@ enableIPv4Masquerade: true
bpf:
masquerade: true
# [parity] flow observability / isolation audit
# [parity] roll the agents when cilium-config changes. The chart default (false) rewrites the
# ConfigMap but leaves the pod template alone, and cilium-agent only reads it at STARTUP — so a
# merged config change is SILENTLY INERT. Measured 2026-10-04..05: the Hubble exporter keys were
# live in cilium-config and the HelmRelease said UpgradeSucceeded, while all three agents were
# still pre-merge pods exporting nothing for over a day. Cost: config edits now roll the agents
# (one node at a time via maxUnavailable: 1), which can briefly blip the VIPs on the lease-holding
# node. Full rationale in helmrelease.yaml.
rollOutCiliumPods: true
hubble:
enabled: true
relay:
enabled: true
ui:
enabled: true
# [parity] static flow export -> cilium-agent stdout -> Alloy -> Loki. Records the PRE-SNAT
# client address for Plex's raw tcp/32400, which Plex's own log cannot show (every LB Service here
# is externalTrafficPolicy: Cluster, so the VIP lease holder SNATs the client). Scoped BY PORT,
# not namespace: the flow carrying the real client IP does not match a namespace filter. Full
# rationale, measurements and the rejected alternatives are in helmrelease.yaml.
export:
static:
enabled: true
filePath: stdout
allowList:
- '{"destination_port":["32400"]}'
- '{"source_port":["32400"]}'
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
l2announcements:
enabled: true
# [parity] k3s provides the cgroupv2 mount; do not auto-mount
# [parity] the host's cgroup2 root is /sys/fs/cgroup (systemd mounts it); do not auto-mount.
# NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so socket-LB attached to the agent's own
# cgroup and host processes lost Service/LB translation (2026-10-03 — see helmrelease.yaml).
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the
+24
View File
@@ -20,6 +20,30 @@ A wildcard `*.keithjackson.dev` matches only one label deep, so `<svc>.home.keit
its own record. **`internal` services need NO public record** — PiHole resolves them to the internal
Gateway VIP (split-horizon). ExternalDNS manages **`public`** services' records only.
## Public target + in-cluster DDNS (2026-10-03)
Gateway-published records are **CNAMEs to `wan.home.keithjackson.dev`**, not the Gateway VIP:
```
plex.home.keithjackson.dev CNAME wan.home.keithjackson.dev <- external-dns (Gateway `target` annotation)
wan.home.keithjackson.dev A <current WAN IP> <- cloudflare-ddns (ddns.yaml), every 5m
```
- The `target` annotation is on the **shared Gateway** (`platform/gateway/gateway.yaml`), so it
applies to every route that publishes at all. `internal` routes (`controller: none`) still publish
nothing.
- `ddns.yaml` runs `favonia/cloudflare-ddns` in this namespace with the same `cloudflare-api-token`.
It detects the WAN IP through Cloudflare's trace endpoint, so the pod **must not** carry
`egress-class: vpn`.
- LAN and pods resolve `*.home.keithjackson.dev` to the Gateway VIP via PiHole and CoreDNS
(split-horizon) and never use the public path.
- **Nodes resolve through public DNS** (`node_baseline_dns_servers`), so from a node, the apiserver's
OIDC issuer `keycloak.home.keithjackson.dev` resolves to the WAN IP. kubectl SSO therefore relies on
the router hairpinning (NAT reflection) WAN:443 back to the Gateway.
Check: `dig +short @1.1.1.1 plex.home.keithjackson.dev` → `wan.home.keithjackson.dev.` then the WAN IP;
`kubectl -n external-dns logs deploy/cloudflare-ddns`.
## Secrets
Cloudflare API token referenced **by name** (`cloudflare-api-token`, shared with cert-manager),
+87
View File
@@ -0,0 +1,87 @@
---
# In-cluster dynamic DNS for the home WAN (2026-10-03).
#
# Keeps ONE record, `wan.${BASE_DOMAIN}` (A, IPv4 only, not proxied), pointed at the home WAN's
# current public IP. Every public name external-dns publishes from the shared Gateway is a CNAME to
# it (platform/gateway/gateway.yaml `external-dns.alpha.kubernetes.io/target`), so a WAN IP change
# moves all of them through this one record, with no Git change and no external-dns involvement.
#
# OWNERSHIP SPLIT WITH EXTERNAL-DNS. This record carries no external-dns TXT registry entry, so
# external-dns (policy: sync, owner `prod`) never treats it as its own and never deletes it.
# Conversely this updater manages only the names in DOMAINS and never touches external-dns's records.
#
# IP DETECTION. Default `cloudflare.trace`: an HTTPS request to Cloudflare's trace endpoint, which
# reports the address the request arrived from. Correct here only because this pod is NOT in the
# `vpn` egress class — the CiliumEgressGatewayPolicy (platform/vpn-egress) selects pods by
# `homelab.keithjackson.dev/egress-class: vpn` alone, cluster-wide. Labelling this pod `vpn` would
# publish the VPN exit IP instead of the WAN IP.
#
# TOKEN. Reuses this namespace's `cloudflare-api-token` (same Zone:DNS:Edit token as external-dns
# and cert-manager) — no new secret.
#
# TIER NOTE. This lives in the gating `platform` Flux Kustomization (wait: true) next to
# external-dns. If it ever crashloops, `platform` goes not-Ready and apps stop reconciling (no
# outage). Kept here rather than in its own non-gating Kustomization to avoid a clusters/** change.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflare-ddns
namespace: external-dns
labels:
app.kubernetes.io/name: cloudflare-ddns
spec:
replicas: 1 # exactly one writer for the record
revisionHistoryLimit: 3
strategy:
type: Recreate # never two updaters at once
selector:
matchLabels:
app.kubernetes.io/name: cloudflare-ddns
template:
metadata:
labels:
app.kubernetes.io/name: cloudflare-ddns
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: cloudflare-ddns
image: favonia/cloudflare-ddns:1.17.1
imagePullPolicy: IfNotPresent
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-api-token
key: api-token
- name: DOMAINS
value: "wan.${BASE_DOMAIN}"
- name: IP6_PROVIDER
value: "none" # IPv4 WAN only; leaves any AAAA alone
- name: PROXIED
value: "false" # DNS-only: Plex/Gitea/etc. terminate TLS on the Gateway
- name: UPDATE_CRON
value: "@every 5m"
- name: DELETE_ON_STOP
value: "false" # a pod restart must not blank the record every CNAME depends on
- name: RECORD_COMMENT
value: "managed by k3s cloudflare-ddns (platform/externaldns/ddns.yaml)"
- name: EMOJI
value: "false"
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
+21 -30
View File
@@ -81,38 +81,29 @@ spec:
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
# ---------------------------------------------------------------------------------------------
# THE PUBLIC-TARGET GAP — STILL OPEN, AND NOT FIXABLE HERE ON v0.21.0 (operator decision)
# THE PUBLIC TARGET — DECIDED 2026-10-03 (operator): every Gateway-published name goes public
#
# The five `exposure: public` services (plex, gitea, seerr, keycloak, nextcloud) publish records
# that point at 192.168.2.20 — the Gateway VIP, an unroutable RFC1918 address — so they are not
# actually reachable from the internet either. Their records are LEFT EXACTLY AS THEY ARE by
# this change, for two independent reasons:
# Until 2026-10-03 every gateway-sourced record answered with the Gateway VIP (192.168.2.20,
# RFC1918), so nothing published here was reachable from the internet. Two facts constrained the
# fix, and both still hold:
# * `external-dns.alpha.kubernetes.io/target` is read in v0.21.0's gateway-httproute source ONLY
# from the GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
# gw.Annotations)`) and from a ListenerSet listener — NEVER from an HTTPRoute. A target on the
# shared Gateway therefore moves EVERY gateway-sourced record at once.
# * The home WAN IP is residential and can change, so a literal IP in Git would rot.
#
# 1. THE VALUE IS NOT ESTABLISHED. The one public IP this repo knows is 136.51.115.252
# (clusters/prod/kustomization.yaml, where the zone's `*.keithjackson.dev` wildcard also
# points). It belongs to the LEGACY non-k3s stack and currently serves a placeholder
# certificate. Whether prod's public services belong behind it, behind a new forward, or
# are not meant to be public at all is an operator decision. Breaking five services' DNS
# on a guess is worse than leaving a known-wrong-but-stable state.
# 2. THE MECHANISM CANNOT BE SCOPED TO THEM. `external-dns.alpha.kubernetes.io/target` is a
# real override, but in v0.21.0's gateway-httproute source it is read ONLY from the
# GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
# gw.Annotations)`) and from a ListenerSet listener — NEVER from the HTTPRoute. So there is
# no per-service target override while every public service shares the `shared` Gateway.
# Annotating the Gateway would move EVERY gateway-sourced record, which after this change
# still includes auth, crafty, grafana, headlamp and the apex — i.e. it would publish a
# routable address for admin UIs. That is strictly worse than the status quo.
#
# SO THE DECISION THE OPERATOR OWNS IS TWO-PART, and no knob is added here until the first half
# is answered, because a knob that cannot take effect invites someone to set it and expect one:
# (a) WHAT address should the five public names answer with — 136.51.115.252 as-is, a new
# port-forward/WAN address, or nothing (drop `exposure: public` to `internal` instead)?
# (b) HOW should they be separated from the non-public names that share the Gateway? Options,
# roughly in ascending cost: a SECOND Gateway (or a ListenerSet) carrying the target
# annotation, with only the public services' routes attached; or publishing the five names
# from the `crd` source (DNSEndpoint objects the renderer emits for `public` services),
# which restores per-service targeting without a second Gateway.
# Only (b) is a manifest change; (a) is a fact about the home WAN that has to be checked.
# The operator's answers: (a) yes, ALL of them — the five `exposure: public` apps AND the admin
# UIs (auth, crafty, grafana, headlamp, the apex), which sit behind Keycloak / oauth2-proxy;
# (b) the address is kept current IN-CLUSTER. Implementation:
# * platform/gateway/gateway.yaml sets `target: wan.${BASE_DOMAIN}` on the shared Gateway, so
# each published name becomes a CNAME to that one hostname;
# * platform/externaldns/ddns.yaml (favonia/cloudflare-ddns) keeps `wan.${BASE_DOMAIN}` an A
# record at the current WAN IP. That record has no external-dns TXT registry entry, so this
# instance never owns or garbage-collects it.
# Internal services are unchanged: their routes carry `controller: none` (above), which is
# checked BEFORE the target is read, so they still publish nothing. The `service` source (the L4
# LoadBalancers crafty-mc, unifi) does not read the Gateway's annotation and still publishes its
# LB VIP. Whether those should go public is a separate, still-open question.
# ---------------------------------------------------------------------------------------------
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
#
+2
View File
@@ -8,3 +8,5 @@ resources:
# SOPS-encrypted Cloudflare token (env CF_API_TOKEN) — SAME token as cert-manager, second copy in
# the external-dns namespace. Flux decrypts at reconcile.
- cloudflare-api-token.sops.yaml
# DDNS for `wan.${BASE_DOMAIN}` — the CNAME target every Gateway-published record points at.
- ddns.yaml
+17
View File
@@ -7,6 +7,23 @@ kind: Gateway
metadata:
name: shared
namespace: gateway
annotations:
# PUBLIC DNS TARGET (2026-10-03, operator decision). Without this, external-dns publishes every
# route on this Gateway with the Gateway's OWN address (${GATEWAY_VIP}, RFC1918), which is
# unreachable from the internet. With it, every published `<svc>.${BASE_DOMAIN}` (and the apex)
# becomes a CNAME to `wan.${BASE_DOMAIN}`, an A record that platform/externaldns/ddns.yaml keeps
# pointed at the home WAN's current public IP. The WAN forwards 80/443 to ${GATEWAY_VIP}.
#
# Scope is EVERY route on this Gateway that publishes at all — the operator chose to make the
# admin UIs (auth, grafana, headlamp, crafty, apex) publicly resolvable alongside the five
# `exposure: public` apps. `internal` services stay unpublished via their
# `external-dns.alpha.kubernetes.io/controller: none` route annotation, which external-dns checks
# BEFORE it reads this target. external-dns v0.21.0 reads `target` from the Gateway only, never
# from an HTTPRoute — see platform/externaldns/helmrelease.yaml.
#
# LAN and in-cluster clients are unaffected: PiHole and CoreDNS answer ${BASE_DOMAIN} with
# ${GATEWAY_VIP} directly (split-horizon), so they never take this public path.
external-dns.alpha.kubernetes.io/target: "wan.${BASE_DOMAIN}"
spec:
gatewayClassName: cilium
listeners:
+125
View File
@@ -88,3 +88,128 @@ This proves the R-IDP-02 property (unauthenticated cannot reach the upstream) on
3. Log in as a **non-platform-admins** user -> expect **403** (authenticated but not authorized).
4. Confirm the session cookie is `.home.keithjackson.dev`-scoped (single sign-on across the other
protected UIs).
## Observability: what this component can and cannot see (measured 2026-09-30)
> **SUPERSEDED — read "Update 2026-10-05" at the end of this section first.** The gate sidecars
> **are** scraped now and the TSDB **does** hold `oauth2_proxy_*` for them. The two subsections
> below are kept as the dated record of how this was diagnosed, not as current state.
`servicemonitor.yaml` scrapes **this namespace's** deployment and nothing else. On prod that is
two targets, `job="oauth2-proxy"`, both `up=1`. The complete set of oauth2-proxy-specific series
in the TSDB is:
count by (__name__) ({__name__=~"oauth2_proxy.*"})
oauth2_proxy_requests_in_flight => 2
**The per-app gate sidecars are not scraped, and there is nothing to scrape.** Six apps run an
`oauth2-proxy` container of their own — crafty, jackett, pihole, qbittorrent, radarr, sonarr —
each with `--http-address=0.0.0.0:4180` and **no `--metrics-address`**, so oauth2-proxy's metrics
listener never starts. They declare only a `http:4180` container port, no `metrics` port exists on
their Services, and `kubectl get servicemonitors,podmonitors -A` selects none of them. Any alert
written against `oauth2_proxy_requests_total{namespace="jackett"}` would be structurally inert.
This matters because the gate's characteristic failure is invisible by construction: an
unauthenticated **XHR** gets a `302` to Keycloak rather than a `401`, the browser follows it
against a live SSO session with no prompt, and the call resolves **200 carrying an HTML page**
where the app expected JSON. Nothing in the request path reports an error. On 2026-09-30 jackett
spent a burst of requests in exactly that state, re-established a session, and lost it 26 seconds
into a 5-minute session, while `jackett-down` sat at 1 replica available the whole time.
`auth-visibility-alerts.yaml` therefore alerts on **the gap**, not on the incident:
`OAuth2ProxyGateUnobserved` compares `kube_pod_container_info{container="oauth2-proxy"}` against
`up{container="oauth2-proxy"}` — two already-scraped families — and names every namespace running
a gate that nothing observes. It fires for six namespaces today and clears per-namespace as each
one gains a scrape. `runbooks/oauth2-proxy-auth-failures.md` is the by-hand substitute meanwhile,
and it reads the sidecar logs out of Loki (Alloy collects them; 72h retention).
**To close the gap for an app**, three things are needed together, on that app's own manifest:
`--metrics-address=0.0.0.0:44180` on the sidecar, a `metrics` containerPort plus the matching
Service port, and a ServiceMonitor selecting it. Only then does the auth-churn rule — sustained
`code="302"` as a share of `oauth2_proxy_requests_total`, held across a window longer than the
session lifetime — become writable rather than decorative.
### Update 2026-10-01: the listener exists; the scrape path does not
PR #28 added `--metrics-address=0.0.0.0:44180` and a named `metrics` container port to the five
fleet sidecars (jackett, radarr, sonarr, qbittorrent, pihole). `podmonitor.yaml` in this directory
discovers them by the pod label `homelab.keithjackson.dev/auth-mode: forward-auth` — a **PodMonitor**
rather than a ServiceMonitor because no Service exposes 44180 and adding one would mean changing the
renderer.
The endpoint is real: port-forwarded straight to the jackett pod, `/metrics` returns **http=200,
10208 bytes**, exporting `oauth2_proxy_requests_in_flight`, `oauth2_proxy_requests_total` (with
`code` labels) and `oauth2_proxy_response_duration_seconds`. That is verified by payload, not by the
flag appearing in `args` — a distinction worth keeping, because the two claims came apart here.
**The scrape is nevertheless dropped.** From a pod in ns `monitoring`, both `:44180` and `:4180` on
the jackett pod time out; the shared proxy's `:44180` answers. The only difference is that ns
`oauth2-proxy` has **no NetworkPolicy at all**, while each app namespace has a `default-deny` whose
`policyTypes` include `Ingress` with no `ingress` rules (deny-all) plus a single CNP admitting
`fromEntities: [ingress]` to port 4180 — the Gateway's Envoy and nothing else. Prometheus matches no
clause.
So the targets read `up == 0` and the TSDB is still empty for those namespaces. This is the same bug
and the same fingerprint as `platform/longhorn/networkpolicy-metrics.yaml` documents for Longhorn,
and the same narrow fix applies: ns `monitoring` → the gate pod label → TCP 44180 only. It cannot
ship from this directory, because `oauth2-proxy` and `apps-wave-0` are **siblings** in the Flux graph
(both `dependsOn: [platform-config]`), so a NetworkPolicy naming ns `jackett` would apply on today's
cluster and fail on a cold rebuild. It belongs with each app's own rendered policy.
Consequence for `auth-visibility-alerts.yaml`: its `unless` clause requires `up == 1`, not merely a
present `up` series. A discovered-but-failing target publishes `up` with value 0, so the bare form
would have cleared `OAuth2ProxyGateUnobserved` the moment the PodMonitor landed — reporting the gates
as observed while nothing was being scraped. The unit test pins that scenario; deleting the `== 1`
fails it and nothing else.
### Update 2026-10-05: the scrape works
PR #49 (merged `21c88b0`) added the missing third piece. `catalog/render.py`'s
`r_monitoring_ingress_policy` now also fires for the gate sidecar's metrics port, keyed on
`_needs_oauth2_sidecar`, rendering `<app>-allow-monitoring-scrape` per app: ns `monitoring` → that
app's `endpointSelector` → TCP 44180 only. Five objects, one each for jackett, pihole, qbittorrent,
radarr, sonarr.
Measured on prod 2026-10-05 after Flux applied it (`apps-wave-0` 21:37:01Z, `apps-wave-1`
21:37:46Z):
| | before | after |
|---|---|---|
| target health (6) | `down` | `up` |
| `lastError` | `context deadline exceeded` | `''` |
| `lastScrapeDuration` | **10.0s** — exactly the `scrapeTimeout` | **0.001–0.006s** |
| `up` | `0` × 6 | `1` × 6 |
count by (__name__) ({__name__=~"oauth2_proxy_.*",
namespace=~"jackett|radarr|sonarr|qbittorrent|pihole"})
oauth2_proxy_requests_in_flight => 6
oauth2_proxy_requests_total => 16
oauth2_proxy_response_duration_seconds_bucket => 108
oauth2_proxy_response_duration_seconds_sum => 9
oauth2_proxy_response_duration_seconds_count => 9
sum by (namespace) (oauth2_proxy_requests_total)
qbittorrent => 35072 radarr => 23560 sonarr => 1284
`oauth2_proxy_requests_total` is absent for jackett and pihole simply because that counter is not
created until the gate has served its first request.
**So the auth-churn rule is now writable rather than decorative** — sustained `code="302"` as a
share of `oauth2_proxy_requests_total`, held across a window longer than the session lifetime. That
is the rule the 2026-09-30 incident above could not be caught by. It is still unwritten.
**Alert outcome:** all 5 `TargetDown` resolved, and 5 of 6 `OAuth2ProxyGateUnobserved` with them.
At `repeat_interval: 12h` with `group_by: [alertname, namespace]`, those 11 groups had been
re-notifying at roughly **22 emails/day** since 2026-10-02; that drops to 2/day.
**`crafty` still fires, and that is correct.** It is a hand-maintained Deployment under
`platform/crafty`, outside the renderer, whose `oauth2-proxy` container has **no
`--metrics-address` at all** — no listener, so no port to open and nothing for the PodMonitor's
pod-label selector to match. Closing it needs the flag and a `metrics` containerPort on crafty's
own manifest first, then a hand-written allowance beside it. Open follow-up work.
**The paragraph above headed "To close the gap for an app" is superseded**: a *Service* port and a
ServiceMonitor are **not** required. A PodMonitor targets pod ports directly, which is why no
Service exposes 44180 and why the renderer needed no Service change. The three things actually
needed are `--metrics-address` + named container port, a PodMonitor, and the monitoring-ingress
allowance.
@@ -0,0 +1,177 @@
---
# promtool unit tests for OAuth2ProxyGateUnobserved (platform/oauth2-proxy/auth-visibility-alerts.yaml).
#
# WHY. The rule is an `unless` between two aggregations over DIFFERENT metric families, and the
# failure modes of that shape are both silent: a label that does not survive `count by` makes the
# right-hand side match nothing (the alert fires for the scraped namespace too, i.e. permanently),
# and a right-hand side that over-matches makes it fire for nobody (i.e. inert). Neither is
# visible by reading the expression, and neither is visible on a live cluster where the answer
# happens to look plausible. So all four states are pinned: fires for an unscraped namespace,
# stays quiet for a scraped one, honours `for: 1h`, and STOPS once a scrape appears.
#
# HOW TO RUN. The one repo-wide harness, by the companion convention:
# ./infra/devcontainer/dev.sh run bash -c 'platform/monitoring/tests/run.sh platform/oauth2-proxy/auth-visibility-alerts.promtool-test.yaml'
# promtool is pinned in infra/devcontainer/versions.env; never run it off the host PATH.
#
# THE SERIES BELOW ARE REAL SHAPES read off live prod Prometheus on 2026-09-30 before being
# copied here — kube_pod_container_info as kube-state-metrics emits it (job="kube-state-metrics",
# its own instance/pod labels, image/image_id/container_id) and `up` as the oauth2-proxy
# ServiceMonitor emits it (endpoint="metrics", port 44180). The point of using the real shapes is
# that `count by (namespace)` has to discard all of them; a test with tidy two-label series would
# pass while the real expression collapsed differently.
#
# ASSERTIONS USE `ALERTS` RATHER THAN exp_alerts: the description annotation is long prose and
# comparing it verbatim would couple this file to every wording change while proving nothing the
# ALERTS series does not already prove.
rule_files:
- auth-visibility-alerts.rules.yaml
evaluation_interval: 1m
tests:
# ---------------------------------------------------------------------------------------------
# 1. THE STATE PROD IS ACTUALLY IN (2026-09-30): jackett runs a gate sidecar with no scrape;
# the shared oauth2-proxy namespace runs two that ARE scraped. Asserts the alert fires for
# jackett and NOT for oauth2-proxy, and that `for: 1h` is honoured (pending at 59m).
- interval: 1m
name: an unscraped gate namespace fires after 1h; the scraped one never does
input_series:
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="jackett",pod="jackett-c5bfcbfb8-c75f9",service="kube-prometheus-stack-kube-state-metrics",uid="5b57d48f-e7d3-4050-9cd1-16aaaa052996"}'
values: '1x130'
# The shared instance: kube-state-metrics sees both replicas...
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://aa11bb22",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-cqz27",service="kube-prometheus-stack-kube-state-metrics",uid="1111aaaa-0000-0000-0000-000000000001"}'
values: '1x130'
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://cc33dd44",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-bscxg",service="kube-prometheus-stack-kube-state-metrics",uid="1111aaaa-0000-0000-0000-000000000002"}'
values: '1x130'
# ...and both replicas are genuinely scraped, so `up` carries container="oauth2-proxy" there.
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.1.189:44180",job="oauth2-proxy",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-cqz27",service="oauth2-proxy"}'
values: '1x130'
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.2.47:44180",job="oauth2-proxy",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-bscxg",service="oauth2-proxy"}'
values: '1x130'
promql_expr_test:
# Not yet firing at 59m — `for: 1h` has not elapsed. Guards against someone "simplifying"
# the hold away and turning every rollout into a page.
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing"}'
eval_time: 59m
exp_samples: []
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="pending",namespace="jackett"}'
eval_time: 59m
exp_samples:
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="pending",namespace="jackett",severity="warning"}'
value: 1
# Firing for jackett past the hold...
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing"}'
eval_time: 65m
exp_samples:
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett",severity="warning"}'
value: 1
# ...and never for the namespace that IS scraped. This is the assertion that catches an
# `unless` whose right-hand side stopped matching: without it, a broken rule that fires for
# every gate namespace would pass the test above.
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",namespace="oauth2-proxy"}'
eval_time: 65m
exp_samples: []
# The count must be 1 per namespace-with-a-gate, not 1 overall: a `count by (namespace)`
# that lost its `by` would collapse to a single unlabelled series.
- expr: 'count by (namespace) (kube_pod_container_info{container="oauth2-proxy"}) unless count by (namespace) (up{container="oauth2-proxy"})'
eval_time: 65m
exp_samples:
- labels: '{namespace="jackett"}'
value: 1
# ---------------------------------------------------------------------------------------------
# 2. THE FIX LANDING. jackett's sidecar gains --metrics-address and a ServiceMonitor 70m in, so
# `up` starts carrying container="oauth2-proxy" for namespace="jackett" under a DIFFERENT job
# name than the shared proxy's. The alert must stop. This is what makes the rule a gap
# tracker rather than a permanent nag, and it pins that the match is on `container`, not on
# a hardcoded job="oauth2-proxy" (which would keep firing forever after a correct fix).
- interval: 1m
name: the alert clears once the sidecar is actually scraped
input_series:
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="jackett",pod="jackett-c5bfcbfb8-c75f9",service="kube-prometheus-stack-kube-state-metrics",uid="5b57d48f-e7d3-4050-9cd1-16aaaa052996"}'
values: '1x130'
# Absent for the first 70 minutes, then present. `_x` is "no sample"; the scrape did not
# exist yet.
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.1.213:44180",job="jackett-auth",namespace="jackett",pod="jackett-c5bfcbfb8-c75f9",service="jackett-auth-metrics"}'
values: '_x70 1x60'
promql_expr_test:
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett"}'
eval_time: 65m
exp_samples:
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett",severity="warning"}'
value: 1
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",namespace="jackett"}'
eval_time: 80m
exp_samples: []
# ---------------------------------------------------------------------------------------------
# 3. THE REGRESSION GUARD FOR `== 1` — the scenario that exposed a real bug on 2026-10-01.
#
# A target is DISCOVERED but every scrape FAILS: `up` exists with value 0. This is not
# hypothetical — it is the exact state podmonitor.yaml creates today, because the app
# namespaces' default-deny drops the scrape (measured; see podmonitor.yaml).
#
# With the original `unless count by (namespace) (up{container="oauth2-proxy"})`, the `up`
# SERIES EXISTS, so `count by (namespace)` returns 1, `unless` removes the namespace, and the
# alert GOES QUIET while nothing is being scraped at all — a green signal standing in for an
# absent one, which is the precise disease this whole rule was written to cure. Deleting the
# `== 1` makes THIS test fail and every other test in this file still pass, which is the
# property that makes it worth having.
- interval: 1m
name: a discovered-but-FAILING target does not count as observed (up==0 must still fire)
input_series:
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="jackett",pod="jackett-6877fcd9f5-ht9d9",service="kube-prometheus-stack-kube-state-metrics",uid="5b57d48f-e7d3-4050-9cd1-16aaaa052996"}'
values: '1x130'
# The PodMonitor target exists and never succeeds: NetworkPolicy drops the packets, so the
# scrape times out and `up` is published as 0 rather than going absent.
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.0.189:44180",job="oauth2-proxy-sidecars",namespace="jackett",pod="jackett-6877fcd9f5-ht9d9"}'
values: '0x130'
promql_expr_test:
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett"}'
eval_time: 65m
exp_samples:
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett",severity="warning"}'
value: 1
# And the inner clause itself yields NOTHING while the scrape is failing — which is the
# mechanism the alert depends on, asserted directly so a future reader can see why the
# `== 1` matters without re-deriving it. (Scenario 4 below covers the other direction:
# a succeeding scrape really does clear the alert, so `== 1` has not made the rule
# impossible to satisfy.)
- expr: 'count by (namespace) (up{container="oauth2-proxy"} == 1)'
eval_time: 65m
exp_samples: []
# ---------------------------------------------------------------------------------------------
# 4. A FLAPPING target: up goes 1 -> 0 and stays there. The alert must come BACK, not latch
# cleared. Covers the ordering bug where a once-observed namespace is permanently excluded.
- interval: 1m
name: a target that starts working and then fails re-fires
input_series:
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="radarr",pod="radarr-7d55cbffb9-42hmh",service="kube-prometheus-stack-kube-state-metrics",uid="3333cccc-0000-0000-0000-000000000001"}'
values: '1x250'
# Healthy for the first 60m, then every scrape fails for the rest.
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.1.77:44180",job="oauth2-proxy-sidecars",namespace="radarr",pod="radarr-7d55cbffb9-42hmh"}'
values: '1x60 0x180'
promql_expr_test:
# Quiet while it was working.
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",namespace="radarr"}'
eval_time: 55m
exp_samples: []
# Firing again an hour after it broke.
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="radarr"}'
eval_time: 125m
exp_samples:
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="radarr",severity="warning"}'
value: 1
# ---------------------------------------------------------------------------------------------
# 5. NO GATE, NO ALERT. A namespace with no oauth2-proxy container must never appear, or the
# rule would page for every app on the platform the day someone widened the matcher.
- interval: 1m
name: namespaces without a gate sidecar are never reported
input_series:
- series: 'kube_pod_container_info{container="plex",container_id="containerd://feed0001",endpoint="http",image="ghcr.io/example/plex:1",image_id="ghcr.io/example/plex@sha256:feed0001",image_spec="ghcr.io/example/plex:1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="plex",pod="plex-0",service="kube-prometheus-stack-kube-state-metrics",uid="2222bbbb-0000-0000-0000-000000000001"}'
values: '1x130'
promql_expr_test:
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved"}'
eval_time: 65m
exp_samples: []
@@ -0,0 +1,137 @@
---
# oauth2-proxy AUTH-VISIBILITY alerting (R-OBS-04).
#
# ============================================================================================
# WHY THIS FILE EXISTS — THE 2026-09-30 JACKETT FAILURE THAT NOTHING OBSERVED
# ============================================================================================
# Jackett's UI stopped working. Reloading never fixed it and the user was never re-prompted to
# log in. The sidecar's own access log had the whole story:
#
# [oauthproxy.go:1026] No valid authentication in request. Initiating login.
# GET "/api/v2.0/indexers/yts/config?_=..." HTTP/1.1 ... 302 400
# GET "/api/v2.0/indexers/thepiratebay/config?_=..." HTTP/1.1 ... 302 409
# ... ten of these at 15:30:53, then a fresh [AuthSuccess] at 15:31:06,
# then "No valid authentication in request" again 26 SECONDS LATER, inside the same session.
#
# NOTHING ANYWHERE ELSE RECORDED IT. oauth2-proxy answers an unauthenticated XHR with a 302 to
# Keycloak rather than a 401; the browser follows it silently, the live SSO session satisfies it
# without a prompt, and the XHR resolves 200 carrying a 44 KB HTML dashboard where the app's
# JavaScript wanted JSON. From the browser, from the user, and from any uptime check, the service
# is healthy. `jackett-down` (apps/wave-0/jackett.yaml) watches
# kube_deployment_status_replicas_available, which was 1 throughout and would have been 1 if the
# gate had rejected every request ever made.
#
# ============================================================================================
# WHY THIS FILE DOES NOT CONTAIN THE OBVIOUS ALERT — MEASURED, prod, 2026-09-30
# ============================================================================================
# The alert you would want is "a gated app is serving a sustained rate of auth redirects to a
# user who is already authenticated". It is NOT WRITABLE against this cluster's TSDB, and the
# reason is this file's real subject:
#
# THE PER-APP GATE SIDECARS EXPORT NO METRICS AT ALL.
#
# $ promtool query instant :9090 'count by (__name__) ({__name__=~"oauth2_proxy.*"})'
# oauth2_proxy_requests_in_flight => 2 <- the SHARED proxy's two replicas. That is ALL.
#
# PARTLY SUPERSEDED 2026-10-01, and the correction matters more than the original. PR #28 gave
# the five FLEET sidecars `--metrics-address=0.0.0.0:44180` and a named `metrics` port, and the
# endpoint genuinely serves (verified by payload: http=200, 10208 bytes, all three
# `oauth2_proxy_*` families present — not merely by the flag being in `args`). podmonitor.yaml
# in this directory now discovers them. But the scrape is still DROPPED by the app namespaces'
# default-deny ingress, so the TSDB remains empty for those namespaces; and crafty still has no
# metrics listener at all. The rule below is unchanged in purpose and still has work to do.
#
# Every sidecar (crafty, jackett, pihole, qbittorrent, radarr, sonarr) ran with
# `--http-address=0.0.0.0:4180` and NO `--metrics-address`, so oauth2-proxy's metrics listener
# was never started; the container declared only port `http:4180`, there was no `metrics` port on
# any of their Services, and there was no ServiceMonitor or PodMonitor for any of them anywhere
# in the cluster (measured: `kubectl get servicemonitors,podmonitors -A`). Six gates, zero
# telemetry. An alert written against `oauth2_proxy_requests_total{namespace="jackett"}` would
# have been PERMANENTLY INERT — present, evaluating, silent, and indistinguishable from health.
# This repo has shipped that mistake before (VeleroNoRecentBackup, the Longhorn rules), which is
# why `conformance/tests/l0-static/alert-metrics-have-scrape-targets` exists, and why the
# temptation was refused here rather than ratcheted into a register.
#
# The SHARED proxy IS scraped (job="oauth2-proxy", 2 targets, up=1) but it is not in the data
# path for any sidecar-gated app — each app gates itself — and its current pods export no
# `oauth2_proxy_requests_total` at all, having proxied nothing since they started. An alert on
# the shared instance would not have fired on this incident either. Saying so is the point.
#
# So this file alerts on the CONDITION THAT MADE THE INCIDENT INVISIBLE, not on the incident.
# That condition is measurable today, from two already-scraped metric families, and it is the
# precondition for ever being able to alert on the incident itself. It self-clears the moment a
# sidecar gains a metrics endpoint and a scrape — at which point the auth-churn rule becomes
# writable and belongs here beside it. See runbooks/oauth2-proxy-auth-failures.md, which is the
# by-hand equivalent an operator uses in the meantime.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: oauth2-proxy-auth-visibility
namespace: oauth2-proxy
labels:
release: kube-prometheus-stack # kube-prometheus-stack's ruleSelector
app.kubernetes.io/name: oauth2-proxy
spec:
groups:
- name: oauth2-proxy.auth-visibility
rules:
- alert: OAuth2ProxyGateUnobserved
# A namespace runs an oauth2-proxy gate container, and Prometheus holds no scrape
# target for that container in that namespace.
#
# BOTH SIDES ARE ALREADY-SCRAPED FAMILIES, deliberately:
# * kube_pod_container_info — kube-state-metrics, the declared owner in
# alert-metrics-have-scrape-targets. It sees the container whether or not the
# container exports anything, which is exactly the property needed here.
# * up — UNIVERSAL. `up` carries the `container` label for any
# ServiceMonitor/PodMonitor target, so this works for a future per-app scrape
# without knowing what job name it will pick.
# Nothing in this expression touches `oauth2_proxy_*`, so the rule cannot itself be
# inert for the reason it is reporting.
#
# MEASURED ON PROD 2026-09-30 — this expression returned six namespaces:
# {namespace="crafty"} {namespace="jackett"} {namespace="pihole"}
# {namespace="qbittorrent"} {namespace="radarr"} {namespace="sonarr"}
# and excluded namespace="oauth2-proxy", whose two replicas ARE scraped. It fires on
# the state the cluster is in; it is not a rule waiting for a hypothetical.
#
# `== 1` IS LOAD-BEARING, AND IT IS A BUG FIX (2026-10-01). This clause was originally
# a bare `up{container="oauth2-proxy"}`, which was WRONG in the one way that matters
# here: a target that is DISCOVERED BUT FAILING still publishes `up`, with value 0. So
# the series exists, `count by (namespace)` returns 1, `unless` removes the namespace,
# and the alert GOES QUIET while nothing whatsoever is being scraped. That is this
# branch's own disease — a green signal standing in for an absent one — reproduced
# inside the alert written to cure it. It was not hypothetical: podmonitor.yaml lands
# five targets whose scrape is DROPPED by NetworkPolicy (measured; see that file), so
# the bare form would have silently cleared on the very next commit. `== 1` means
# "observed" requires a scrape that actually succeeds.
#
# A failing target is therefore reported by BOTH this rule and kube-prometheus-stack's
# generic TargetDown. That duplication is deliberate: TargetDown says "a target is
# down", this says "an authentication gate is unobserved", and during an incident those
# are different sentences. The overlap is the cheap half of the trade.
expr: |
count by (namespace) (kube_pod_container_info{container="oauth2-proxy"})
unless
count by (namespace) (up{container="oauth2-proxy"} == 1)
# 1h, not 5m: a rollout briefly has a running sidecar whose target has not been
# discovered yet, and this condition is a standing architectural gap, never an
# emergency. Long enough that no deploy can produce it.
for: 1h
labels:
severity: warning
annotations:
summary: "oauth2-proxy gate in {{ $labels.namespace }} exports no metrics"
description: >-
The {{ $labels.namespace }} namespace runs an oauth2-proxy gate container and
Prometheus has NO scrape target for it, so every authentication decision that gate
makes — every redirect to Keycloak, every 403 on a group check, every session that
silently fails to hold — is invisible to metrics and to alerting. This is not a
theoretical gap: on 2026-09-30 the jackett gate answered a burst of the app's own
XHR calls with 302s to Keycloak, re-established a session, and lost it again 26
seconds later, and the only record anywhere was a line in the sidecar's stdout.
No alert could have fired, because there is nothing to alert on. Until a scrape
exists the runbook's LogQL queries are the substitute; the fix is
`--metrics-address=0.0.0.0:44180` on the sidecar plus a `metrics` port and a
ServiceMonitor, after which the auth-churn rule becomes writable.
runbook_url: "https://${BASE_DOMAIN}/runbooks/oauth2-proxy-auth-failures"
+9
View File
@@ -14,3 +14,12 @@ resources:
- oauth2-proxy-credentials.sops.yaml
- httproute.yaml # forward-auth sign-in/callback ingress (was referenced in-place by platform-config)
- servicemonitor.yaml # Prometheus scrape (was referenced in-place by platform-config)
# Alerting on whether the gates are OBSERVABLE at all. Travels with the servicemonitor above
# for the same reason it does: monitoring.coreos.com CRDs are registered by the `platform`
# tier, which is Ready before this tier's platform-config dependency completes.
- auth-visibility-alerts.yaml
# Discovers the PER-APP gate sidecars' :44180 metrics endpoints by pod label (PR #28 added the
# listener; no Service exposes it, so this is a PodMonitor, not a ServiceMonitor). The scrape is
# still denied by the app namespaces' default-deny ingress — read podmonitor.yaml's header
# before concluding the targets should be up.
- podmonitor.yaml
+143
View File
@@ -0,0 +1,143 @@
---
# PodMonitor for the PER-APP oauth2-proxy gate sidecars (R-OBS-04).
#
# ============================================================================================
# WHY A PodMonitor AND NOT A ServiceMonitor
# ============================================================================================
# PR #28 gave all five fleet sidecars `--metrics-address=0.0.0.0:44180` and a named `metrics`
# container port. It did NOT add a Service port, and measured on prod 2026-10-01 none of the five
# Services exposes 44180:
#
# jackett jackett: [('port-9117', 9117), ('authproxy', 4180)]
# radarr radarr: [('port-7878', 7878), ('authproxy', 4180)]
# sonarr sonarr: [('port-8989', 8989), ('authproxy', 4180)]
# qbittorrent qbittorrent: [('port-8080', 8080), ('authproxy', 4180)]
# pihole pihole: [('port-80', 80), ('authproxy', 4180)]
#
# A ServiceMonitor selects Service PORTS, so it would have nothing to select. A PodMonitor targets
# pod ports directly and bypasses Services entirely — so this needs no change to the Services, and
# therefore no change to `catalog/render.py` or the `apps/wave-*` renders and goldens.
#
# SELECTOR: the `homelab.keithjackson.dev/auth-mode: forward-auth` POD LABEL, not a namespace list.
# Measured 2026-10-01: that label is present on exactly the five fleet gate pods (jackett, radarr,
# sonarr, qbittorrent, pihole) and absent from crafty's, which is a separately-managed deployment
# (platform/crafty) that still has no `--metrics-address` at all. So the selector describes the
# PROPERTY being scraped rather than enumerating today's apps, and a sixth forward-auth app is
# picked up with no edit here. `namespaceSelector.any: true` because those five namespaces are
# created by the apps-wave-0/1 tiers, which this tier does not and must not depend on.
#
# ============================================================================================
# STATUS: THE SCRAPE WORKS. Verified on prod 2026-10-05 (PR #49 merged as 21c88b0).
# ============================================================================================
# All 6 targets in pool podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0 read `up == 1`,
# health=up, lastError='', lastScrapeDuration 0.001-0.006s. The TSDB holds oauth2_proxy_* for
# these namespaces for the first time: 5 metric names / 148 series, with live counters
# (oauth2_proxy_requests_total 35072 qbittorrent, 23560 radarr, 1284 sonarr). All 5 TargetDown
# and 5 of 6 OAuth2ProxyGateUnobserved resolved; only namespace="crafty" still fires, and that
# is correct — see CRAFTY below.
#
# The missing piece was an ingress allowance, and it now ships WITH EACH APP, rendered by
# catalog/render.py's r_monitoring_ingress_policy as `<app>-allow-monitoring-scrape`:
# ns monitoring -> the app's endpointSelector -> TCP 44180 only. See WHY NOT IN THIS DIRECTORY
# below for why it is rendered per-app instead of shipped from this tier.
#
# Keep the history below. It is why the fix is shaped the way it is, and `up == 1` in
# auth-visibility-alerts.yaml is load-bearing precisely because of it.
#
# --------------------------------------------------------------------------------------------
# HISTORY: 2026-10-02 -> 2026-10-05, THE SCRAPE WAS DENIED BY NETWORKPOLICY FOR 63.7h
# --------------------------------------------------------------------------------------------
# This PodMonitor was NECESSARY AND NOT SUFFICIENT: the endpoint served, and the path from ns
# monitoring was blocked. Both halves measured on prod 2026-10-01, read-only:
#
# THE ENDPOINT IS HEALTHY — port-forward straight to the jackett pod's 44180:
# GET /metrics -> http=200 bytes=10208
# # TYPE oauth2_proxy_requests_in_flight gauge
# # TYPE oauth2_proxy_requests_total counter
# # TYPE oauth2_proxy_response_duration_seconds histogram
# oauth2_proxy_requests_total{code="200"} 3
# oauth2_proxy_requests_total{code="302"} 2
#
# THE SCRAPE PATH IS DROPPED — from a pod in ns monitoring (grafana, which sits beside
# Prometheus and so occupies the same policy position) to the jackett pod IP:
# http://10.0.0.189:44180/metrics -> wget: download timed out
# http://10.0.0.189:4180/ping -> wget: download timed out (even the "allowed" port)
# http://<shared-proxy>:44180/metrics -> 200, oauth2_proxy_* present (control: ns oauth2-proxy
# has NO NetworkPolicy, which is the only reason the
# shared instance has ever been scrapable)
#
# WHY: ns jackett's `default-deny` NetworkPolicy is `podSelector: {}` with
# `policyTypes: [Ingress, Egress]` and an `egress` rule ONLY — no `ingress` key, which in
# NetworkPolicy semantics is deny-all-ingress. The only ingress allowance is the CNP
# `jackett-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180 — the Gateway's Envoy
# and nothing else. Prometheus matches no clause and its packets are dropped, not refused.
#
# THE TARGETS READ `up == 0` FROM 2026-10-02 05:09 UTC UNTIL THE ALLOWANCE LANDED — one
# continuous 63.7h episode (a 15d query_range shows 1 episode per namespace, not flapping),
# starting 11 minutes after this file was created, i.e. TargetDown's `for: 10m`. It was never
# silenced, and that was the right call: `OAuth2ProxyGateUnobserved` in auth-visibility-alerts.yaml
# requires `up == 1`, precisely so a discovered-but-failing target does not read as "observed"
# (see that file's header — this exact trap is why the `== 1` is there).
#
# The signature to recognise if it ever returns: lastScrapeDuration pinned at EXACTLY the
# scrapeTimeout (10.0s) with `context deadline exceeded`. That is a DROP, not a refusal — a
# refused connection fails in milliseconds. Post-fix the same targets scrape in 1-6ms.
#
# THIS IS THE SAME BUG LONGHORN HAD, and the platform has a documented fix idiom for it:
# platform/longhorn/networkpolicy-metrics.yaml, whose header records the identical fingerprint
# measured on 2026-09-13 — ServiceMonitor in place, 3 targets DISCOVERED, `up` 0/0/0, TSDB empty,
# `lastError: context deadline exceeded` (a drop, not a refusal). The fix there is a narrow
# additive `networking.k8s.io/v1` NetworkPolicy: ns monitoring -> the pod label, that one port.
#
# WHY NOT IN THIS DIRECTORY (the reasoning the fix followed). It would have to be five objects, one per app
# namespace, and those namespaces are created by apps-wave-0/1. Measured Flux graph, 2026-10-01:
# oauth2-proxy dependsOn=['platform-config'] <- this tier
# apps-wave-0 dependsOn=['platform-config'] <- SIBLING, no ordering between them
# apps-wave-1 dependsOn=['apps-wave-0']
# They are siblings, so on a cold bootstrap there is no guarantee ns jackett exists when this tier
# applies, and a NetworkPolicy naming a missing namespace fails to apply. It would work on today's
# running cluster and break on a rebuild — the "works now, broken on restore" shape this repo
# keeps paying for. Longhorn's precedent is safe only because longhorn-system is created by the
# same tier that ships its policy. The allowance therefore belongs WITH THE APPS, rendered next to
# the rest of each app's network policy — i.e. in the catalog/renderer. THAT IS WHERE IT LANDED:
# `r_monitoring_ingress_policy`, keyed on `_needs_oauth2_sidecar`, one narrow rule per app.
#
# --------------------------------------------------------------------------------------------
# CRAFTY: STILL UNOBSERVED, AND THIS FILE CANNOT HELP IT
# --------------------------------------------------------------------------------------------
# `OAuth2ProxyGateUnobserved{namespace="crafty"}` still fires after the fix, correctly. crafty is
# a hand-maintained Deployment under platform/crafty, OUTSIDE the renderer, and its oauth2-proxy
# container has NO `--metrics-address` at all (measured 2026-10-05) — so its metrics listener
# never starts and there is no port to open. The selector below therefore does not match it, and
# the per-app allowance is never rendered for it. Closing crafty's gap needs the flag plus a
# `metrics` containerPort added to its own manifest FIRST; the allowance is the second half, and
# it would have to be written by hand there rather than rendered. Open follow-up work.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: oauth2-proxy-sidecars
namespace: oauth2-proxy
labels:
release: kube-prometheus-stack # kube-prometheus-stack's podMonitorSelector
app.kubernetes.io/name: oauth2-proxy
homelab.keithjackson.dev/purpose: metrics-scrape
spec:
namespaceSelector:
# The gate sidecars live in the app namespaces, not this one. `any` rather than a fixed list so
# a new forward-auth app needs no edit here — the pod label below is what scopes this.
any: true
selector:
matchLabels:
# The PROPERTY, not the app list. Present on exactly the five fleet gate pods (measured
# 2026-10-01); absent from crafty, which has no metrics listener yet.
homelab.keithjackson.dev/auth-mode: forward-auth
podMetricsEndpoints:
# By PORT NAME (`metrics`, 44180), never by number: the name is what PR #28 declared on the
# container, and a numeric port here would silently stop matching if the port were renumbered.
- port: metrics
path: /metrics
interval: 30s
# 10s, matching the drop signature above: a DENIED path does not refuse, it hangs until the
# scrape times out. Leaving the default would make each failing target hold a scrape slot for
# the full interval.
scrapeTimeout: 10s
+50 -8
View File
@@ -51,6 +51,36 @@ rule groups still evaluate against nothing and **cannot fire**:
| Flux (`gotk_*`) | `FluxReconciliationFailure`, `FluxResourceSuspended`, `ImageUpdateAutomationFailing`, `ImagePolicyStalled`, `UpdateHealthGateFailed` |
| Hubble (`hubble_drop_total`) | `HubbleDeniedFlowSpike` |
| etcd (`kubeEtcd` disabled for k3s, deliberately) | no etcd fsync alerting exists at all |
| per-app oauth2-proxy sidecars (`oauth2_proxy_*`) — updated 2026-10-01 | no authentication alerting exists at all for crafty, jackett, pihole, qbittorrent, radarr, sonarr |
The oauth2-proxy row is the newest, and it has **moved twice in two days** — worth reading in order,
because each step looked like the end of it.
**2026-09-30 — nothing to scrape.** `count by (__name__) ({__name__=~"oauth2_proxy.*"})` returned
exactly one family, `oauth2_proxy_requests_in_flight`, from the two replicas of the **shared**
forward-auth instance. All six per-app gate sidecars ran without `--metrics-address`, so their metrics
listener never started, and no ServiceMonitor or PodMonitor selected them.
**2026-10-01 — the listener exists, and the scrape is still blocked.** PR #28 added
`--metrics-address=0.0.0.0:44180` and a named `metrics` port to the five fleet sidecars, and the
endpoint genuinely serves (port-forwarded: `http=200`, 10208 bytes, all three `oauth2_proxy_*`
families, `oauth2_proxy_requests_total{code="302"}` present). `platform/oauth2-proxy/podmonitor.yaml`
now discovers them by pod label. **But the app namespaces' `default-deny` NetworkPolicy drops the
scrape**: from a pod in ns `monitoring`, both `:44180` and `:4180` on the jackett pod time out, while
the shared proxy's `:44180` answers — the only difference being that ns `oauth2-proxy` has no
NetworkPolicy at all. So the targets are discovered and `up == 0`, and the TSDB is still empty for
those namespaces. **This is the same bug Longhorn had**, with the same fingerprint; the fix idiom is
`platform/longhorn/networkpolicy-metrics.yaml` (ns monitoring → one pod label → one port), and for
these apps it has to be rendered alongside each app's own policy rather than shipped from the
oauth2-proxy tier, which is a *sibling* of `apps-wave-0` in the Flux graph and so cannot rely on those
namespaces existing yet.
`OAuth2ProxyGateUnobserved` (`platform/oauth2-proxy/auth-visibility-alerts.yaml`) alerts on the gap
itself from `kube_pod_container_info` and `up` — two families that *are* scraped. Note its `up == 1`:
a discovered-but-failing target publishes `up` with value **0**, so the earlier bare form would have
gone quiet the moment the PodMonitor landed, while nothing was being scraped. That near-miss is the
whole reason to distrust a cleared alert here. Crafty remains outside all of this — no metrics
listener at all. See `oauth2-proxy-auth-failures`.
Each affected runbook says so on its own page and gives the by-hand equivalent. See `targets-down`.
@@ -108,6 +138,14 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
- **`keycloak-db`** — Keycloak's CloudNativePG database is unavailable.
- **`keycloak-client-secrets`** — the client/URL reconciler failing or silent. The 2026-09-06 lockout that
every green signal missed.
- **`oauth2-proxy-auth-failures`** — a gated app "is up but does not work". Linked from
`OAuth2ProxyGateUnobserved`. The gate answers an unauthenticated **XHR** with a 302 to Keycloak
rather than a 401, the browser follows it silently against a live SSO session, and the call
resolves **200 carrying HTML** where the app's JavaScript wanted JSON — so the user is never
re-prompted and every uptime check stays green. Tells the gate's refusal from the group check
from the app's own login, from the sidecar log and from Loki, with the 2026-09-30 jackett trace
as the worked example. **Read the caveat**: the per-app gate sidecars export no metrics at all,
so these logs (72h of them) are the only record this class of failure leaves.
### Operator procedures (no alert links here)
- **`plex-claim`** — claiming the Plex Media Server. **Claimed 2026-09-29**; the page now only
@@ -115,14 +153,18 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
plex.tv/claim — so it is a timed sequence, scripted as `infra/plex/claim-ship.sh`:
token → `sops` → PR → gate → merge → **forced** `flux reconcile` → pod roll (~2m45s measured).
Includes why a changed Secret does not restart anything, and why an expired token is harmless.
- **`plex-quicksync`** — hardware transcoding on the host iGPU. GitOps puts `/dev/dri` in the pod
(`hardware.gpu: intel-quicksync`); **a human still has to tick Settings → Transcoder → "Use hardware
acceleration when available"**, and no environment variable can do it (the image supports no
`PLEX_PREFERENCE*`, verified). The page names the real preference key (`HardwareAcceleratedCodecs`,
read out of the binary), says which widely-cited key **does not exist**, and is explicit that the
**only** proof is `Transcode (hw)` on the Plex dashboard — Plex renders no ServiceMonitor, so no
alert could ever fire. Also: the device-cgroup failure to expect first and its device-plugin
fallback, and why a `:latest` pull can silently break it.
- **`plex-quicksync`** — hardware transcoding on the host iGPU. **Confirmed working 2026-10-02**
(VAAPI decode *and* encode, zero-copy). GitOps requests `gpu.intel.com/i915` on the pod
(`hardware.gpu: intel-quicksync`, satisfied by the `intel-gpu-plugin` DaemonSet — a `/dev/dri`
hostPath was measured not to work: device-cgroup `EPERM`); **a human still has to tick Settings →
Transcoder → "Use hardware acceleration when available"**, and no environment variable can do it
(the image supports no `PLEX_PREFERENCE*`, verified). **The proof is `Plex Media Server.log`'s
`final decoder: / final encoder:` line, not the Plex dashboard** — the dashboard showed nothing
while hardware transcoding was working, so its silence proves nothing; the page carries the
redacting grep (`X-Plex-Token` lives in that log), the WRONG log file to avoid, and two more
documented false leads (an empty `/proc/*/fd` scan, and a missing `HardwareAcceleratedCodecs` key).
Plex renders no ServiceMonitor, so no alert could ever fire. Also: **`Pending` stalls the whole app
tier** and its one-field escape hatch, and why a `:latest` pull can silently break it.
- **`oauth2-proxy-session`** — a gated UI loads but its saves quietly do nothing, and you are never
re-prompted to log in. Tells the three stacked auth layers apart from the sidecar's own access log
(the four lines that matter, including the upstream `-` that means the proxy answered a request the
+7
View File
@@ -85,10 +85,17 @@ configMapGenerator:
- node-io=node-io.md
- node-memory=node-memory.md
- node-memory-swap=node-memory-swap.md
# Linked from OAuth2ProxyGateUnobserved, and the diagnostic procedure for any gated app
# that "is up but does not work" — the gate answers an unauthenticated XHR with a 302,
# not a 401, so the failure arrives as an HTTP 200 carrying the wrong body.
- oauth2-proxy-auth-failures=oauth2-proxy-auth-failures.md
# Operator procedure, not alert-linked. Reading the per-app oauth2-proxy sidecar log to tell
# Keycloak, the sidecar and the app's own login apart. Cookie collision between two sidecars
# presents as random session loss in ONE app's log and is only legible with both side by side,
# so the page has to be reachable at 3am without going looking for it in git.
# SIBLING PAGE to oauth2-proxy-auth-failures above, and they are deliberately both here: this
# one is the SESSION-LIFECYCLE procedure (why a session died), that one is the DIAGNOSTIC
# TRIAGE (which of the three layers refused, and how to tell a 302-to-200 from a real success).
- oauth2-proxy-session=oauth2-proxy-session.md
# Operator procedure, not an alert page (no alert links to it — the
# alert-runbooks-resolvable check treats that as advisory). It is served
+244
View File
@@ -0,0 +1,244 @@
# oauth2-proxy auth failures — telling the three layers apart
Linked from `OAuth2ProxyGateUnobserved`, and the page to open whenever a gated app "is up but
does not work": a UI that loads and then shows nothing, a page that never finishes, a list that
stays empty, a save that silently does not save. The alert is about the *visibility* gap; this
page is the diagnostic procedure, and it works whether or not the alert ever fired.
## What this means here
Every app behind Keycloak on this platform has **three** places a request can be refused, and
from a browser they look identical — because two of the three answer with **HTTP 200**.
| Layer | What refuses | What the browser sees |
|---|---|---|
| 1. The gate (oauth2-proxy sidecar) | no valid session cookie | **302 to Keycloak**, then usually **200** |
| 2. The gate's group check | session valid, wrong group | 403, or a redirect loop |
| 3. The app itself | the app's own login / API key | the app's own 401 or login page |
**Layer 1 is the one that hides.** oauth2-proxy answers an unauthenticated request with a
redirect, not a 401 — correct for a browser navigation, disastrous for an **XHR**. The app's
JavaScript asks for JSON, the gate answers `302`, the browser follows it silently, the live
Keycloak SSO session satisfies it with no prompt, and the XHR resolves **200 with an HTML
dashboard in the body**. `fetch()` sees a success. The user sees an empty page. Reloading does
nothing, and nobody is ever asked to log in, because from the browser's point of view nothing
failed.
The worked example, jackett on 2026-09-30, straight from the sidecar log:
[oauthproxy.go:1026] No valid authentication in request. Initiating login.
GET "/api/v2.0/indexers/yts/config?_=..." HTTP/1.1 ... 302 400
GET "/api/v2.0/indexers/thepiratebay/config?_=..." HTTP/1.1 ... 302 409
... ten of these at 15:30:53
[AuthSuccess] Authenticated via OAuth2: Session{email:... created:2026-09-30 15:31:06
expires:2026-09-30 15:36:06 refresh_token:true groups:[platform-admins]}
GET "/UI/Dashboard" 200 44379
GET "/api/v2.0/indexers?_=..." 200 1501229
... and 26 seconds later, inside that same 5-minute session:
[oauthproxy.go:1026] No valid authentication in request. Initiating login.
A session created at 15:31:06, valid until 15:36:06, not honoured at 15:31:32. That is the
signature of the session being **re-established over and over instead of held** — a cookie that
another proxy is overwriting, or a cookie that never comes back at all — not of Keycloak being
down and not of the app being broken.
**Read this before you trust any silence (updated 2026-10-01).** The five fleet gate sidecars now
*do* run a metrics listener on `:44180`, and `platform/oauth2-proxy/podmonitor.yaml` discovers
them — but **the scrape is dropped by the app namespaces' `default-deny` NetworkPolicy**, so those
targets read `up == 0` and the TSDB holds nothing for them. Crafty has no listener at all. Only
the shared `oauth2-proxy` namespace instance is genuinely scraped, and it is not in the data path
for any of these apps. So there is still nothing to graph and nothing that could have alerted.
**The logs below remain the only record that this class of failure happened.** Check which state
you are in before you trust a quiet dashboard — "First commands" step 1 tells you in one query.
## First commands
`kubectl` does not exist on the workstation; everything runs in the pinned container, and always
with an explicit context.
**1. Which namespaces have an unobserved gate** (this is what the alert measures):
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n monitoring exec prometheus-kube-prometheus-stack-prometheus-0 \
-c prometheus -- /bin/promtool query instant http://localhost:9090 \
"count by (namespace) (kube_pod_container_info{container=\"oauth2-proxy\"}) unless count by (namespace) (up{container=\"oauth2-proxy\"} == 1)"'
```
**1b. If a namespace is listed, is it "no target" or "target failing"?** The two need different
fixes and the alert deliberately does not distinguish them:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n monitoring exec prometheus-kube-prometheus-stack-prometheus-0 \
-c prometheus -- /bin/promtool query instant http://localhost:9090 \
"up{container=\"oauth2-proxy\"}"'
```
No series for the namespace → nothing discovers it (no PodMonitor match; check the pod carries
`homelab.keithjackson.dev/auth-mode=forward-auth` and a named `metrics` port). A series with
value **0** → discovered but every scrape fails; get the reason from the target's `lastError`:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n monitoring port-forward svc/kube-prometheus-stack-prometheus 19090:9090 & sleep 5; \
curl -s localhost:19090/api/v1/targets | python3 -c "
import json,sys
for t in json.load(sys.stdin)[\"data\"][\"activeTargets\"]:
if t[\"labels\"].get(\"container\")==\"oauth2-proxy\":
print(t[\"labels\"].get(\"namespace\"), t[\"health\"], t.get(\"lastError\",\"\")[:90])"'
```
`context deadline exceeded` is a **drop**, not a refusal — that is NetworkPolicy, not a dead
endpoint. Confirm the endpoint itself is fine by port-forwarding straight to the pod, which
bypasses policy entirely:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n jackett port-forward \
$(kubectl --context k3s-prod -n jackett get pod -l app.kubernetes.io/name=jackett -o name | head -1) \
14418:44180 & sleep 5; curl -s -o /dev/null -w "http=%{http_code} bytes=%{size_download}\n" \
http://127.0.0.1:14418/metrics'
```
`http=200` with a few KB means the listener is healthy and the path is what is broken. As of
2026-10-01 that is the state of all five fleet apps, and the fix is an ingress allowance from ns
`monitoring` to port 44180 — see `platform/longhorn/networkpolicy-metrics.yaml` for the idiom.
**2. The sidecar log — but you must grep it.** The gate's stdout is ~11,500 lines a day per app
and almost all of it is the kubelet health probe (`GET "/ping" ... 200`). A plain
`kubectl logs --tail=100` shows you nothing but `/ping` and looks perfectly healthy. Filter:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n jackett logs deploy/jackett -c oauth2-proxy --since=2h \
| grep -v "/ping" | grep -E "No valid authentication|AuthSuccess|403|401|callback"'
```
Substitute the namespace: `crafty`, `jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`.
**3. The same question across every gate at once, from Loki.** Alloy collects all of these, so
you can ask about the whole fleet and about a window that has already scrolled out of
`kubectl logs`. Port-forward Loki and query it:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n monitoring port-forward svc/loki 13100:3100 & sleep 5; \
curl -s -G --data-urlencode "query=sum by (namespace) (count_over_time({container=\"oauth2-proxy\"} |= \"No valid authentication in request\" [24h]))" \
http://127.0.0.1:13100/loki/api/v1/query | python3 -m json.tool | grep -E "namespace|value" '
```
and the denominator, which is what makes the number mean something:
```
... --data-urlencode "query=sum by (namespace) (count_over_time({container=\"oauth2-proxy\"} |= \"AuthSuccess\" [24h]))" ...
```
**How to read the ratio.** A healthy sign-in is roughly **one or two** `No valid authentication`
lines per `AuthSuccess` — the first request of a session is unauthenticated by definition. A
ratio in the tens means sessions are not surviving. Measured on prod 2026-09-30 (24h):
| namespace | `No valid authentication` | `AuthSuccess` | ratio |
|---|---|---|---|
| crafty | 81 | 1 | 81:1 |
| radarr | 67 | 1 | 67:1 |
| jackett | 39 | 4 | ~10:1 |
| pihole, qbittorrent, sonarr | 0 | 0 | (unused in the window) |
All three of those are the failure, not the baseline.
**The cleanest worked case of all — radarr, measured 2026-10-01.** After the cookie fix landed,
jackett dropped to **0** `No valid authentication` lines in 13,612 (from 39), and `removing
session` / `cookie signature not valid` went to **0 on both apps** (radarr had 8 and 8 before).
The eviction fingerprint is gone. But radarr still logged **206** rejections — and sampling 209 of
them, **every single one was the same request**:
GET "/signalr/messages/negotiate" ... 302 <- browser UA, over and over
That is one left-open browser tab. Its cookie is gone, its SignalR socket keeps trying to
reconnect, and every attempt is silently 302'd to Keycloak. Nobody is at the keyboard, so nobody
is ever re-prompted; the tab just sits there looking like a working page that has stopped
updating. **This is the entire failure class in one line**: a non-navigation request answered with
a redirect, forever, with no error surfaced anywhere.
So when you read a rejection count, **always group by request path before concluding anything**:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n radarr logs deploy/radarr -c oauth2-proxy --since=24h \
| grep -v "/ping" | grep "No valid authentication" -A 1 \
| grep -oE "\"[^\"]+\" HTTP" | sort | uniq -c | sort -rn | head'
```
One path dominating means an orphaned client, not a broken gate — annoying but harmless, and it
clears when the tab is closed. Many different paths from one session means the session itself is
not surviving, which is the real defect. A per-app `--api-route` is the structural fix for the
first shape: it makes those paths answer **401** instead of 302, so the client fails loudly
instead of looping. Note that a count of **0 `AuthSuccess`** alongside a high rejection count
means nobody has signed in since the last restart — so `--cookie-refresh` has never run, and no
session-lifecycle conclusion can be drawn from that window at all.
**4. Did the gate admit the user, or did the app refuse them?** Look for the `[AuthSuccess]` line
and what follows it. If `[AuthSuccess]` appears and the very next thing is the *app's own* login
page or the app's 401, the gate did its job and layer 3 is your problem — that is the Crafty
shape, and `runbooks/crafty-admin-credential.md` covers it.
**5. Cookie names, when sessions do not survive.** Two oauth2-proxy instances emitting the
**same** cookie name on the **same** domain hold different cookie-secrets, so each overwrites the
other's cookie and the loser logs exactly the `No valid authentication` line above. List what
each gate is configured to emit (arguments only — never print a Secret's data):
```
infra/devcontainer/dev.sh run bash -c \
'for ns in crafty jackett pihole qbittorrent radarr sonarr; do printf "%-12s " "$ns"; \
kubectl --context k3s-prod -n $ns get deploy $ns \
-o jsonpath="{range .spec.template.spec.containers[?(@.name==\"oauth2-proxy\")]}{.args}{end}" \
| tr "," "\n" | grep -E "cookie-name|cookie-domain" | tr "\n" " "; echo; done'
```
A gate with no `--cookie-name` is using oauth2-proxy's default `_oauth2_proxy`. If two of them
share that default **and** a zone-wide `--cookie-domain=.home.keithjackson.dev`, they are
evicting each other, and the symptom is intermittent and depends on which app you visited last.
## Likely causes, most likely first
1. **Cookie collision between two gates on one domain.** Default cookie names plus a zone-wide
cookie domain. Signature: high `No valid authentication`-to-`AuthSuccess` ratio, sessions
dying seconds after being created, symptom depends on browsing order, and *nothing* wrong at
Keycloak. Fix is a unique `--cookie-name` and a host-scoped `--cookie-domain` per gate — see
`decisions/2026-09-29-crafty-oauth2-proxy-signin.md`, which found and fixed exactly this.
2. **The cookie is too large to come back.** With `--pass-access-token` the session carries the
access token; a fat Keycloak token pushes the cookie past 4 KB and it gets split into
`_oauth2_proxy_0`, `_oauth2_proxy_1`… If a chunk is dropped the session is unreadable and the
gate logs the same line. Look for numbered cookie names in the log, and for a session that
never survives a single navigation.
3. **The XHR path is not the navigation path.** The app's API is under a prefix the gate handles
differently, or the app issues cross-origin XHRs the cookie does not accompany. Signature:
`/UI/...` returns 200 and `/api/...` returns 302 in the *same second*, which is precisely the
jackett trace above.
4. **Session lifetime shorter than the app's polling interval,** with no refresh. The jackett
session above was a 5-minute session. If `refresh_token:true` is present and sessions still
die early, the refresh is failing, not missing.
5. **Group check, not authentication.** `--allowed-group` is a floor: a valid Keycloak session in
the wrong group is refused *after* a successful login, which reads as a loop. Look for 403 and
for the `groups:[...]` list on the `[AuthSuccess]` line.
6. **The app, not the gate.** `[AuthSuccess]` followed by the app's own login page or 401.
## When to escalate or stop
- **Stop and hand over if the fix is a manifest change.** Everything above is read-only
diagnosis. Changing a `--cookie-name`, adding `--metrics-address`, or editing an alert goes
through a branch and a PR; `kubectl edit` on a gate is drift that the next Flux reconcile
reverts, and in the meantime you have an unreproducible cluster.
- **Stop if you find yourself about to print a Secret.** The cookie-secret and client-secret live
in `oauth2-proxy-credentials` and the per-app equivalents. Compare *key names* and whether two
Secrets differ; never dump `.data` or `.stringData`. A password has been leaked into a
transcript here exactly this way.
- **Escalate to the platform owner if Keycloak itself is the suspect** — check `KeycloakDown` and
`KeycloakDatabaseDown` first (`runbooks/keycloak-down.md`, `runbooks/keycloak-db.md`). In the
2026-09-30 incident Keycloak was healthy throughout and blaming it would have cost days.
- **The honest limit of this page.** With no metrics from the sidecars there is no history beyond
Loki's **72h** retention (`platform/monitoring/loki.yaml`, `retention_period: 72h`). If the
report is older than three days, the evidence is gone and the only remaining move is to
reproduce it. That is the cost of the gap `OAuth2ProxyGateUnobserved` is tracking, and it is
the reason to close it rather than silence it.
+194 -55
View File
@@ -1,5 +1,10 @@
# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
**Status: hardware transcoding is CONFIRMED WORKING on prod as of 2026-10-02** — VAAPI for both
decode *and* encode, zero-copy, measured in Plex's own server log (see "How to verify"). The manual
toggle has been ticked. This page is therefore now mostly a **verification and regression**
procedure, and its old verification section was **wrong** — see the correction at the top of
"How to verify".
**Alert:** none, and none is possible — see "How to verify" below. This is an **operator procedure**,
not an incident page. It is indexed here because the feature it completes is **inert until a human
clicks one button**, and a switched-off feature that looks shipped is exactly what this page exists
@@ -33,7 +38,7 @@ is in git**:
| Half | Who does it | State |
|---|---|---|
| The render device is inside the container **and openable** | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders `resources.limits: {gpu.intel.com/i915: 1}` on the app container. The `intel-gpu-plugin` DaemonSet (`platform/intel-gpu-plugin/`) injects the device **and the matching device-cgroup allow rule**. Flux applies it. | Done by merging that change. |
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **NOT done.** `HardwareAcceleratedCodecs` was **absent** from `Preferences.xml` when this page was written — the toggle has never been on. |
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **Done, 2026-10-02**, and confirmed from the **log** rather than from a preference key. `HardwareAcceleratedCodecs` is **still absent** from `Preferences.xml` with hardware transcoding working, so its absence proves nothing — see first-command #4. |
**There is no GitOps path to the second half, and that is verified rather than assumed.** A bounded
grep across the running image's entire init surface — `/etc/cont-init.d`, `/etc/services.d`,
@@ -79,8 +84,8 @@ also the only thing that produces the cgroup allow rule. There is no graceful-de
The one improvement is that the loss is no longer silent.
What this does **not** do, so nobody reads more into a green gate than is there: it does not enable
hardware transcoding (that is the manual toggle below), a green gate only proves the **manifest
renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin
hardware transcoding (that is the manual toggle below — done 2026-10-02), a green gate only proves
the **manifest renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin
advertises, which is the scheduler's doing and not a selector in this repo.
## Running the commands in this runbook
@@ -172,17 +177,28 @@ cgroup rule that uid 0 could not buy its way past either. `RW-OPEN-DENIED` with
visible is the **device-cgroup** failure — i.e. the plugin's allow rule did not arrive; see "Likely
causes" #2.
**4. Is the toggle on?** Named key only — never the whole file:
**4. Is the toggle on?** **You cannot answer this from `Preferences.xml` alone.** That mistake is on
this page's record: it told readers to treat a missing `HardwareAcceleratedCodecs` as "never
enabled", and on 2026-10-02 that key was **absent from all 27 attributes** while hardware
transcoding was demonstrably working. Named keys only — never the whole file, it holds
`PlexOnlineToken`:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
grep -o "HardwareAcceleratedCodecs=\"[01]\"" \
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo ABSENT'
grep -o -e "HardwareAcceleratedCodecs=\"[01]\"" -e "HardwareDevicePath=\"[^\"]*\"" \
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo NEITHER-KEY'
```
`ABSENT` ⇒ never enabled (the state this page was written in). `="0"` ⇒ explicitly off. `="1"` ⇒ on,
and the remaining question is whether it *works* — go to "How to verify".
| Reading | What it actually means |
|---|---|
| `HardwareDevicePath="…"` **present** | **Positive signal.** Plex writes this when hardware acceleration is configured; it was present on 2026-10-02 alongside a working VAAPI pipeline. |
| `HardwareAcceleratedCodecs` **absent** | **Nothing. Neither on nor off.** Measured absent with the feature working (2026-10-02). Do not diagnose from it, and do not write it into the file to "fix" it. |
| `HardwareAcceleratedCodecs="0"` | explicitly **off** — the only conclusive reading, and only in the negative. |
| `HardwareAcceleratedCodecs="1"` | on. |
**The log settles it, this file does not.** Go to "How to verify"; the `hardware transcoding:
enabled` and `final decoder/final encoder` lines are the verdict.
## The one manual step
@@ -191,8 +207,11 @@ Plex web UI → **Settings → Transcoder → "Use hardware acceleration when av
The underlying preference key is **`HardwareAcceleratedCodecs`**. That name was confirmed by
extracting strings from the 23 MB `Plex Media Server` binary in the running container — **not** from
recall and not from a forum post. Real neighbouring keys from the same binary, so you can recognise
them and not invent them:
recall and not from a forum post. **But do not expect to see it in `Preferences.xml` after ticking
the box:** on 2026-10-02, with the toggle on and hardware transcoding working, the key was absent and
`HardwareDevicePath` was what had been written. The key exists in the binary; Plex does not
necessarily persist it. Real neighbouring keys from the same binary, so you can recognise them and
not invent them:
| Key | What it is |
|---|---|
@@ -209,42 +228,121 @@ binary. Recorded explicitly so nobody spends an evening hunting for it or, worse
Editing `Preferences.xml` by hand is **not** the procedure. It is app state on the replicated Longhorn
tier, it is in no git commit, and the UI writes the key correctly. Use the UI.
## How to verify — and why there is no alert
## How to verify — the log, not the dashboard
Start a playback that **forces a transcode** (a client that cannot direct-play the source, or pick a
lower quality explicitly), then read Plex's own **Dashboard → Activity**:
**Correction, 2026-10-02.** Until today this page said the Plex dashboard showing `Transcode (hw)`
was "the *only* proof available, and nothing else will ever tell you". **That was false and it cost
the operator real time:** hardware transcoding was fully working — VAAPI decode *and* encode, with
zero-copy — while **Dashboard → Activity showed nothing of the sort**. A check that is silent while
the feature works does not just fail to prove anything; it sends someone hunting a fault that does
not exist. The authoritative check is **Plex's own server log**.
- **`Transcode (hw)`** on the session ⇒ hardware transcoding. **This is the proof.**
- plain **`Transcode`** ⇒ software. The feature is not working, whatever the manifest says.
### The primary check — run this one
**That dashboard is the *only* proof available, and nothing else will ever tell you.**
`catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** —
there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open work
to "alert on software-transcode fallback": with no metric behind it such a rule would be permanently
inert, and this repo has already been burned at scale by rules that evaluated against nothing while
looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence of"). If that
gap is ever worth closing, the honest order is a real exporter first and a rule second.
```
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n plex exec deploy/plex -- \
grep -aiE "hardware transcoding: (final|using)" \
"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log" \
| sed -E "s/(X-Plex-Token|token)=[^&[:space:]]*/\1=REDACTED/g" | tail -5'
```
Corroborating, not a substitute — the server log on the config PVC:
| Line | Verdict |
|---|---|
| `hardware transcoding: final decoder: vaapi, final encoder: vaapi` | **PASS.** Hardware in **both** directions. This is the pass condition. |
| `hardware transcoding: using zero-copy transcoding` | Best case — frames never round-trip through the CPU. Observed on prod 2026-10-02. |
| `hardware transcoding: final decoder: , final encoder: ` (both empty) | It **fell back to software** for that session. |
| no matching line at all | No transcode has been attempted since the log rolled — force one (below), then look again. Absence is not a failure. |
The 2026-10-02 reading on prod, in full, for the shape to recognise:
```
Codecs: hardware transcoding: testing API vaapi for device '/dev/dri/renderD128'
(Intel CoffeeLake-S GT2 [UHD Graphics 630])
hardware transcoding: using hardware decode accelerator vaapi
hardware transcoding: zero-copy support present
hardware transcoding: using zero-copy transcoding
hardware transcoding: final decoder: vaapi, final encoder: vaapi
```
To make a line appear, start a playback that **forces a transcode**: a client that cannot
direct-play the source, or an explicitly lower quality. Direct Play / Direct Stream never reaches
the transcoder and logs nothing.
### Three things to get right about that command
1. **Always keep the `sed`.** `Plex Media Server.log` carries API URLs containing
**`X-Plex-Token=…`**, a live plex.tv credential. Any output that might land in a ticket, a
transcript or a chat window must be redacted — CLAUDE.md §6, and a credential has already been
leaked into a transcript in this repo exactly this way. The redacting form above is the form to
copy; do not "simplify" the pipe out of it.
2. **`Plex Transcoder Statistics.log` is the WRONG FILE.** The name is irresistible and someone will
reach for it. It holds XML `<Variant .../>` session records — **not** ffmpeg/VAAPI output. There
is no decoder/encoder verdict anywhere in it. The file you want is `Plex Media Server.log`.
3. `grep -a` is deliberate: the log carries occasional non-UTF-8 bytes, and without `-a` GNU grep
reports "binary file matches" and prints nothing — which reads exactly like "no hardware
transcoding".
### Secondary and unreliable: the Plex dashboard
**Dashboard → Activity** *may* label a transcoding session `Transcode (hw)`. If it does, it agrees
with the log and you are done. **If it does not, that proves nothing at all** — measured 2026-10-02:
hardware transcoding active and zero-copy, dashboard silent. Never conclude "software transcoding"
from the dashboard and never open an investigation on its say-so. Kept here only because it is the
fastest glance available; it is a convenience, **not evidence**.
### Process-level, while a transcode is in flight: the transcoder's argv
This shows Plex *requesting* hardware, as distinct from reporting it afterwards. Get a shell in the
pod (if `-it` has no TTY under `dev.sh run`, wrap each line in `sh -c "…"` instead):
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
sh -c "grep -i -m20 -e hardware -e vaapi -e hwdevice \
\"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log\""'
'kubectl --context k3s-prod -n plex exec -it deploy/plex -- sh'
```
Treat those strings as advisory: they are Plex's, not this repo's, and they move between versions.
Absence of a match is **not** evidence of software transcoding; a VA-API device open is good evidence
of hardware. The dashboard remains the verdict.
then, inside the pod:
```
ps -eo pid,user,comm | grep -i transcoder
tr '\0' ' ' < /proc/<transcoder-pid>/cmdline | grep -oE '(-hwaccel[a-z_]*|vaapi|renderD[0-9]+)'
```
`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128` present ⇒ Plex is asking for hardware. Keep the
`grep -oE`: the raw argv contains media paths and session identifiers you do not want in a
transcript. This only works while a session is actually transcoding.
### Documented false lead: scanning `/proc/*/fd` for a holder of `renderD128`
**It returns nothing even when hardware transcoding is working.** Measured 2026-10-02: a
`/proc/*/fd` sweep inside the pod found **no** process holding `/dev/dri/renderD128`, while the log
showed `final decoder: vaapi, final encoder: vaapi` for sessions either side of the scan. Transcode
sessions are **short** and the scan landed in a gap between two of them. An empty fd scan is a
**timing artefact, not a finding** — do not report it as evidence of software transcoding, and do
not escalate on it. Use the argv check above (which has the same timing constraint but tells you
something when it does catch a session), or just read the log, which is retrospective and therefore
does not care about timing.
### Why there is no alert — this half was always right
`catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** —
there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open
work to "alert on software-transcode fallback": with no metric behind it such a rule would be
permanently inert, and this repo has already been burned at scale by rules that evaluated against
nothing while looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence
of"). If that gap is ever worth closing, the honest order is a real exporter first and a rule
second.
## Likely causes, most likely first
When the dashboard still says plain `Transcode`:
When the **log** says it fell back to software — `final decoder: , final encoder: ` with both
fields empty. (Not when the dashboard is quiet: the dashboard's silence means nothing, see "How to
verify".)
1. **The toggle is off.** Overwhelmingly the most likely answer, and the reason this page exists.
First-command #4 returns `ABSENT` or `="0"`. Remedy: the manual step above. Check this **before**
investigating anything technical.
1. **The toggle is off.** Historically the most likely answer and the reason this page exists. It was
ticked on 2026-10-02 and should stay ticked, but a `/config` restore or a stray UI save can unset
it. Remedy: the manual step above. **Do not diagnose this from a missing
`HardwareAcceleratedCodecs`** — see first-command #4; only `="0"` is conclusive, and the log line
`hardware transcoding: enabled` is the real signal that the toggle took.
2. **The device cannot be `open()`ed — the plugin's cgroup allow rule did not arrive.** Symptom:
first-command #3 shows `renderD128` present and correctly owned, and the open returns
@@ -276,13 +374,27 @@ When the dashboard still says plain `Transcode`:
4. **The image changed under you.** See "The floating-image caveat". A `:latest` pull can remove the
mechanism this feature leans on with no commit in this repo.
5. **The session was never transcoding.** Direct Play / Direct Stream never touches the transcoder, so
`Transcode (hw)` cannot appear. Confirm the session actually says `Transcode` before treating it as
a hardware failure.
5. **The session was never transcoding.** Direct Play / Direct Stream never reaches the transcoder,
so no `hardware transcoding:` line is written for it at all. An *absent* line is this, not a
failure — confirm a transcode actually happened before treating anything as a hardware fault.
6. **The source needs a codec Quick Sync on this generation cannot handle** — e.g. AV1 decode, which
Coffee Lake's UHD 630 has no engine for. Plex falls back to software *per stream*. Symptom: some
sessions show `(hw)` and others do not. Not a fault.
6. **The *source* codec may be one this iGPU cannot decode — HYPOTHESIS, not a confirmed
diagnosis.** The symptom that points here is fallback on **particular titles** while other
sessions are hardware. Measured on prod 2026-10-02, *between* two fully hardware sessions, one
session logged:
```
hardware transcoding: enabled, but no hardware decode accelerator found
hardware transcoding: final decoder: , final encoder:
```
The platform was demonstrably working either side of it, so the likely explanation is the **file**,
not the cluster: UHD 630's media engine decodes H.264, HEVC 8-bit and 10-bit, and VP9 — but **not
AV1**, and not 10-bit 4:2:2 HEVC. **This was NOT verified against that particular title.** It is
inferred from the hardware's capability set and is written here as the lead to check first, not as
a finding. So: fallback on *all* files is a platform problem (causes 1-4); fallback on *some*
files, check the source codec before suspecting the device, and expect occasional legitimate
per-title fallbacks to be normal rather than a fault.
## Plex will not schedule
@@ -360,19 +472,30 @@ infra/devcontainer/dev.sh run bash -c \
```
`45-plex-hw-transcode-and-connected-tuner` still present ⇒ the group mechanism is still the one
described here. Gone or renamed ⇒ stop and re-measure. In particular the "no `supplementalGroups`
needed" conclusion in `catalog/plex.yaml` and `schemas/renderer-contract.md` is only true while that
hook exists.
described here: the hook reads the injected device's **own** ownership, which under the device plugin
is `root:root`, so it resolves GID `0` (see "When to escalate or stop"). Gone or renamed ⇒ stop and
re-measure. In particular the "no `supplementalGroups` needed" conclusion in `catalog/plex.yaml` and
`schemas/renderer-contract.md` is only true while that hook exists.
## When to escalate or stop
- **Do not add `supplementalGroups` or a `podSecurityContext` to `catalog/plex.yaml`.** It would be
**inert**, and it would mislead the next person. Measured in-container 2026-09-30: the image's
`cont-init.d` hook stats each `/dev/dri` char device and `usermod -a -G`s the `plex` user into its
group *inside* the container, and the service then execs via `s6-setuidgid plex`, which **replaces**
the process group list from `/etc/group` rather than appending — `Plex Media Server` runs with
`Groups: 100 1000`, root's gid `0` (which PID 1 does hold) gone. Anything Kubernetes supplies is
**inert**, and it would mislead the next person. The image's `cont-init.d` hook stats each
`/dev/dri` char device and `usermod -a -G`s the `plex` user into **that device's** group, inside
the container; the service then execs via `s6-setuidgid plex`, which **rebuilds** the process group
list from `/etc/group` rather than appending to it. Anything Kubernetes supplies is therefore
discarded before Plex starts. `render.py --selftest` asserts that absence deliberately.
- **GID `992` is a hostPath-era number — do not expect it, and do not read the root group as a
defect.** Under the device plugin the injected `/dev/dri/renderD128` arrives **`root:root`**, so
the hook resolves GID **`0`** and adds `plex` to the **root group**. Measured 2026-10-02:
`Plex Media Server` runs with `Groups: 0 100 1000`, and `id plex` reports
`groups=1000(plex),0(root),100(users)`. Under the old hostPath the device was `root:render`
(GID 992 on these nodes) and the reading was `Groups: 44 100 992 1000`. **992 is now irrelevant**:
if any page, comment or older handoff tells you to expect it, that text is stale and the system
you are looking at is working. The root-group membership is an inherent property of the plugin's
`DeviceSpec` — it injects the host device with its host ownership — **not** a defect, and **not** a
reason to reintroduce `supplementalGroups`, which would still be a no-op for the reason in the
bullet above.
- **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will
reject it — and it would not help: `uid 0` failed the same `open()` in the 2026-09-30 measurement,
because root does not bypass the cgroup device gate.
@@ -390,7 +513,8 @@ hook exists.
watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to
lose the DB.
- **Stop and get the operator** if the device cannot be opened from the pod as uid 1000 (cause #2
confirmed) *under the current mechanism*. The next step is a platform change in
confirmed) *under the current mechanism* — note that this open **passed** on 2026-10-02
(`RW-OPEN-OK` as uid 1000 via `s6-setuidgid plex`), so a failure now is a regression. The next step is a platform change in
`platform/intel-gpu-plugin/`, with its own PR, not more poking at this workload.
## Known gaps, deliberately not fixed here
@@ -413,10 +537,25 @@ Recorded so they are findable, and so nobody assumes they were overlooked:
---
Source of truth: `runbooks/plex-quicksync.md`. Index: `/runbooks/`
The node measurements (all three prod nodes) and the in-container image measurements cited here were
taken **2026-09-30** against `plexinc/pms-docker` digest `sha256:e0ab27395614…`
(`plexmediaserver 1.43.4.10903`). The `EPERM` open measurements, the `Groups: 44 100 992 1000`
reading and the `gpu.intel.com/i915: 1` allocatable/DaemonSet readings were taken **2026-10-01**.
Hardware transcoding has **still not been observed working**: the open test has not been re-run under
the device-plugin mechanism (it cannot be, until that merges and Flux reconciles), and the Plex-side
toggle is still unset.
**Hardware transcoding is CONFIRMED WORKING on prod, measured 2026-10-02.** From
`Plex Media Server.log` in the running pod: API `vaapi` on `/dev/dri/renderD128`
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
`zero-copy support present`, `using zero-copy transcoding`, and
`final decoder: vaapi, final encoder: vaapi`. Also measured in the same pod: `RW-OPEN-OK` opening
`/dev/dri/renderD128` as uid 1000 via `s6-setuidgid plex`; the pod spec carrying
`{"limits":{"gpu.intel.com/i915":"1"},"requests":{"gpu.intel.com/i915":"1"}}`; `Groups: 0 100 1000`
and `id plex` → `groups=1000(plex),0(root),100(users)`; and `HardwareDevicePath` present in
`Preferences.xml` while `HardwareAcceleratedCodecs` was absent from all 27 attributes.
Earlier readings, kept for provenance and **superseded** where they conflict: the node measurements
(all three prod nodes) and the in-container image measurements taken **2026-09-30** against
`plexinc/pms-docker` digest `sha256:e0ab27395614…` (`plexmediaserver 1.43.4.10903`); the hostPath
`EPERM` open results and the `Groups: 44 100 992 1000` / GID-992 group reading, **2026-10-01, under
the hostPath mechanism that no longer exists**; and the `gpu.intel.com/i915: 1` allocatable and
`intel-gpu-plugin` DaemonSet readings, **2026-10-01**.
What this page still does **not** establish: that any particular title's software fallback was caused
by its source codec (cause 6 is an unverified hypothesis), and that Plex's dashboard *ever* labels a
session `Transcode (hw)` on this deployment — it has not been observed doing so, which is exactly why
the log is now the primary check.
+148
View File
@@ -0,0 +1,148 @@
# plex-remote-access — getting Plex reachable from outside the LAN, and proving it
**Status: the in-cluster half is shipped; the last mile is TWO MANUAL STEPS and remote access does
NOT work until both are done.** Neither can live in this repo.
**Alert:** none, and none is possible from in-cluster signals — see "Why no alert" below. This is an
**operator procedure**, not an incident page.
**Files:** `apps/wave-0/plex-remote.yaml` (the tcp/32400 LoadBalancer + its CiliumNetworkPolicy),
`clusters/base/cluster-vars.yaml` + `clusters/prod/kustomization.yaml` (`PLEX_L4_VIP`),
`catalog/render.py` (the `EXTRA_UNITS` entry that places the unit), `catalog/plex.yaml` (Plex's own
env — note `ADVERTISE_IP` is deliberately NOT set; see "Why not the Gateway URL").
**Related:** `runbooks/plex-quicksync.md` and `runbooks/plex-claim.md` — the other two Plex
procedures with a manual last mile.
## What was actually broken (measured 2026-10-03)
Plex was serving correctly and was reachable on the LAN. Two independent things stopped remote
clients:
1. **Plex advertised no remote connection at all.** In the live `Preferences.xml`:
`customConnections` **ABSENT**, `ManualPortMappingMode` **ABSENT**, while
`PublishServerOnPlexOnlineKey="1"`. The server was trying to publish itself to plex.tv and had
nothing reachable to publish. Plex's automatic path (UPnP/NAT-PMP) cannot work here: the process
lives in a pod on the Cilium overlay (`10.0.2.136`), it cannot speak UPnP to the SOHO router, and
a mapping to the pod IP would be meaningless if it could.
2. **Nothing from the WAN reached the cluster.** `136.51.115.252:32400` → connection refused.
`136.51.115.252:443` → a **self-signed router certificate** (`C=ZZ, CN=%D`), not the Gateway's
Let's Encrypt wildcard.
The Service was `ClusterIP` only, so port 32400 had no path off-cluster by any route.
**Caveat on that second measurement, because it is easy to over-read:** it was taken from *inside*
the LAN, so strictly it proves there is no NAT hairpin (reflection). It does not prove outside-in
failure. But a router certificate answering 443 means the router's own management UI is bound to the
WAN port, and that *would* shadow a port-forward for genuine outside traffic too. **Verify from a
real outside vantage point** (phone on cellular, or an external port checker) before concluding
either way — this repo has no such vantage point and must not pretend otherwise.
## Step 1 — forward WAN tcp/32400 on the router (operator, off-repo)
WAN tcp/32400 -> 192.168.2.24 (${PLEX_L4_VIP}) port 32400
`192.168.2.24` is a pinned Cilium LB VIP, ARP-announced on the flat LAN by the existing
`CiliumL2AnnouncementPolicy default-l2`. It is **pinned on purpose**: this address is typed into the
router by hand, so it must never move on a reschedule.
While in the router, **check what else is bound to the WAN interface.** If remote management is
listening on 443 (see the certificate measurement above), move it or disable it — otherwise the
Gateway's 80/443 forward is shadowed and the Plex *web UI* over the internet stays broken even
though remote *streaming* works. These are two separate paths and they fail independently.
Confirm the VIP is actually answering on the LAN first, so a failure later is unambiguous:
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex get svc plex-l4-tcp -o wide'
`EXTERNAL-IP` must read `192.168.2.24`. If it is `<pending>`, LB-IPAM could not assign — the address
is outside the pool or already taken; see `clusters/prod/kustomization.yaml` for the pool accounting.
**`192.168.2.24` was never ARP-probed off-cluster.** Only the in-cluster pool accounting was checked
(IPsTotal 11 / IPsUsed 4 / IPsAvailable 7 on 2026-10-03). If the VIP behaves oddly, probe it from a
LAN host — `arping 192.168.2.24` — the way `.23` was probed before it was claimed.
## Step 2 — tick "manually specify public port" in Plex (operator, UI only)
**There is no GitOps path to this and no environment variable for it.** Measured in the running
image on 2026-10-03: a grep of the entire init surface (`/etc/cont-init.d`, `/plex-common.sh`,
`/usr/local/bin`) for `ManualPortMapping` and `PLEX_PREFERENCE` returned **NONE**. The image writes
only six preferences, and this is not one of them. It is the same class of manual last mile as the
`HardwareAcceleratedCodecs` toggle in `runbooks/plex-quicksync.md`.
In the Plex web UI (`https://plex.home.keithjackson.dev`, on the LAN):
**Settings → Remote Access**
- Ensure **Enable Remote Access** is on. (`PublishServerOnPlexOnlineKey` was already `1`.)
- Tick **Manually specify public port** and set it to **32400**.
- **Apply**, then let the panel settle. It should report *"Fully accessible outside your network"*.
Without this tick Plex waits for a NAT mapping it can never obtain and publishes no remote
connection, so the port-forward in step 1 sits there unused.
## How to verify
Do these in order; each one isolates a different half.
**1. The port is open from outside.** From a phone on cellular, or any off-LAN host:
curl -sk -o /dev/null -w '%{http_code}\n' https://136.51.115.252:32400/identity
`200` means the forward and the policy are both good. A hang or refusal means step 1.
**2. Plex itself believes it is reachable.** Settings → Remote Access must say *"Fully accessible
outside your network"*. If it says *"not available outside your network"* while test 1 passes, step 2
was not applied.
**3. plex.tv is handing out a remote connection.** The decisive test, because it is what clients
actually consume: sign in to [app.plex.tv](https://app.plex.tv) from an off-LAN network and confirm
the server appears and plays. A client finds this server through plex.tv's connection list, never
through DNS.
**4. The policy is admitting the traffic.** If test 1 fails *after* the forward is confirmed, the
CiliumNetworkPolicy is the next suspect:
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex get cnp plex-allow-remote-32400'
See `runbooks/denied-flows.md` for reading actual drops.
## Why no alert
Every in-cluster signal is green whether or not remote access works. The Service has endpoints, the
pod is `Running`, the policy is applied, and `plex-down` watches
`kube_deployment_status_replicas_available` — none of which can see a router with a missing
port-forward or an unticked checkbox. An alert would need an off-LAN prober, which this cluster does
not have. **Test 3 above is the only honest check, and it is manual.**
## Why not the Gateway URL (`ADVERTISE_IP`)
The alternative was to leave Plex on `ClusterIP` and set
`ADVERTISE_IP: https://plex.home.keithjackson.dev:443`, which writes Plex's `customConnections`
preference and would have put the server on plex.tv's list via the existing L7 Gateway — no new WAN
hole. That mechanism was verified to work on an already-claimed server (the `ADVERTISE_IP` block at
line 126 of the image's `40-plex-first-run` sits **outside** the claim guard, and `/.firstRunComplete`
lives on the overlay FS, not the `/config` PVC, so it re-runs every pod start), and the Gateway path
itself was measured serving Plex correctly: `HTTP 200`, `claimed="1"`, with `401` on every protected
endpoint.
**The operator chose the native path anyway (2026-10-03), and the reasons are real:** Plex terminates
its own TLS with its `*.plex.direct` certificate, and video streams bypass the Envoy L7 hop instead
of being proxied through it. The cost is a new unfiltered WAN port — acceptable because Plex
authenticates every caller itself. `ADVERTISE_IP` therefore stays unset in `catalog/plex.yaml`, and
the deferral note there is still accurate.
## Known wart: remote clients are classified as LAN
`apps/wave-0/plex-remote.yaml` uses `externalTrafficPolicy: Cluster`, which SNATs the client to the
ingress node's address (`192.168.2.1x`). That falls inside the `192.168.0.0/16` entry of
`ALLOWED_NETWORKS`, so **Plex treats every remote stream as local.** Measured on the Gateway path,
which SNATs the same way: Plex logged `[10.0.2.214:36040 (Allowed Network (WAN))]`.
This is a **metering** effect, not a security one — authorization is unchanged, as the 401s above
show — but Plex's remote-quality and bandwidth limits will not bind on these streams.
`Local` would preserve the client IP and fix it. **Do not switch to it.** Cilium's L2 announcer
elects one lease holder per Service and does *not* follow the backend pod, so the VIP can be
announced by a node with no Plex pod, black-holing the port while everything reports healthy. That
exact change cost this LAN an 8-day Minecraft outage in September 2026 — the full write-up is at the
top of `platform/crafty/service-l4.yaml`. Plex is a single-replica `Recreate` Deployment, so the
node mismatch is the normal case here, not a rare one.
+57 -1
View File
@@ -23,6 +23,7 @@ platform must render, and cites the requirement each mapping satisfies. Changing
| `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 |
| `auth.apiRoutes` | `[]` → the sidecar renders **no** `--api-route`; every unauthenticated request is 302'd to Keycloak | R-IDP-02 |
| `update.channel` | `stable` | R-UPD-01 |
| `update.autoRollbackOnFailedHealth` | `true` | R-UPD-03 |
| `observability.metrics` | `true` | R-OBS-01 |
@@ -160,6 +161,41 @@ platform must render, and cites the requirement each mapping satisfies. Changing
- **Requirements:** **R-STATE-01** (rebuild survival), **R-STATE-02** (Longhorn replication),
**R-STATE-04** (bulk tier). (docs/04)
### `availability` (`replicas`) — workload kind, per-replica volumes, spreading, budget
**Schema 1.10.0.** Availability INTENT. It names no Kubernetes object, no topology key and no
budget — exactly like `hardware.gpu`, and for the same reason: the mechanism below should be
replaceable without a catalog edit.
- **Absent, or `replicas: 1`** → a **Deployment**, standalone PVC(s), `strategy: Recreate` when there
is storage, no PodDisruptionBudget. Byte-identical to pre-1.10.0 output, so every existing app is
untouched.
- **`replicas: N > 1`** → all of the following, together:
| Rendered | Why |
|---|---|
| **StatefulSet** instead of Deployment | A longhorn PVC is **ReadWriteOnce**. One RWO claim cannot attach on two nodes, so a multi-replica Deployment does not degrade — the second pod never schedules. |
| longhorn storage becomes **`volumeClaimTemplates`** | Gives each replica its own claim (`<vol>-<svc>-<ordinal>`, e.g. `config-pihole-0`). The template's `metadata.name` **must** equal the pod volume name the container mounts. The standalone PVC is then **not** emitted — it would be an orphan nothing mounts. |
| `bulk` storage stays a **standalone PVC** | A bulk entry is a static, pre-bound NFS PV over data that already exists outside the cluster and is shared read-only. Per-replica copies are neither possible nor wanted. |
| **required** `podAntiAffinity` on `kubernetes.io/hostname` | `preferred` would let every replica land on one node and deliver none of the intended node-failure tolerance, while every object still reported healthy. |
| `podManagementPolicy: Parallel` | `OrderedReady` serialises first-boot work across replicas; `apps-wave-0` has `timeout: 5m`. There is no ordering requirement between replicas to protect. |
| **PodDisruptionBudget**, `minAvailable: 1` | Stops a *voluntary* disruption (node drain, upgrade) taking the last replica. It does nothing about a node dying — that is what the replica count and anti-affinity are for. Never rendered at `replicas: 1`, where `minAvailable: 1` would block every drain of that node forever. |
| `observability.livenessWorkload.kind` defaults to **`StatefulSet`** | The `up` SLI reads kube-state-metrics by workload kind. A Deployment-shaped rule against a StatefulSet `absent()`s forever — an alert that can never fire. Deriving the default means bumping the replica count cannot leave a stale rule behind. |
**Two costs, both deliberate, neither hidden:**
1. **N replicas need N schedulable nodes.** On a 3-node cluster, losing two nodes leaves a replica
`Pending`; since `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, that **stalls the
app tier**. That coincides with having lost etcd quorum on a 3-node control plane, so nothing is
reconciling at that point anyway — the stall is a symptom, not a new failure mode.
2. **Only declare `> 1` for a service whose on-disk state is reproducible from git.** A replica with
a fresh volume comes up serving whatever it can bootstrap itself and nothing else. For pihole that
prerequisite was done first — the admin password and the adlists were declared in
`decisions/2026-10-04-pihole-declare-state.md` — because otherwise a second replica would have
served DNS while blocking nothing, with a different password.
**Requirements:** R-PLAT-02 (the workload), R-STATE-01/02 (per-replica replicated volumes),
R-OBS-01 (the SLI must follow the rendered kind).
### `hardware` (`gpu`) — host device capability
- **The field is INTENT, never MECHANISM.** `hardware.gpu` is an **enum of capability classes**
(today: `intel-quicksync`), not a device path, not a hostPath, not a resource request. The
@@ -319,7 +355,7 @@ platform must render, and cites the requirement each mapping satisfies. Changing
undeclared), **R-GITOPS-02** (the allowance is authored in the one reviewable object rather than
surviving as a live-applied cluster object). Auditable via Hubble (R-SEC-03, R-OBS-04).
### `auth` (`mode` / `groups[]`) — identity integration (Keycloak / oauth2-proxy)
### `auth` (`mode` / `groups[]` / `apiRoutes[]`) — identity integration (Keycloak / oauth2-proxy)
- `oidc` → register the app as a **Keycloak OIDC client** (declarative realm/client config, GitOps;
client secret SOPS-encrypted). App does its own login redirect.
- `forward-auth` → protect the service's `ingressRoutes` at the **Gateway** with **oauth2-proxy →
@@ -330,6 +366,20 @@ platform must render, and cites the requirement each mapping satisfies. Changing
- `groups[]` → the Keycloak groups permitted access, enforced by the OIDC client mappers /
oauth2-proxy `allowed_groups`. **Schema requires ≥1 group for `oidc`/`forward-auth`** — access is
"in an authorized group," not merely "logged in."
- `apiRoutes[]` (**`forward-auth` only**) → one repeatable **`--api-route=<regex>`** per entry on
the per-app oauth2-proxy sidecar, making matching paths answer **401** instead of a **302** to
Keycloak. Default `[]` renders **no flag at all**, which is the common case. It exists because
oauth2-proxy's built-in AJAX guard fires only on an exact `Accept: application/json`, which
these apps' JavaScript does not consistently send: the XHR is 302'd, the browser follows it
invisibly, the live SSO session satisfies it silently, and the call resolves 200 carrying the
app's **HTML** where the JavaScript expected JSON — and a POST following a 302 is re-issued as
a GET with its **body dropped**. It is **per-service and must never become a renderer
constant**: the path shape belongs to the app (radarr `/signalr/`, jackett `/api/`, pihole
none), and a wrong constant does not merely fail to help — a path a **browser navigates to**,
answered 401, shows a logged-out operator a bare error page and never offers a sign-in. Each
entry must be **anchored at `^/`** and continue with literal path characters; `^/`, `^/.*`,
`.*`, an unanchored `/api/` and any alternation are **rejected** by the schema and by the
renderer (alternation is refused rather than supported because the flag is repeatable).
- Keycloak, oauth2-proxy, and app→Keycloak / proxy→Keycloak / Keycloak→DB flows are ordinary
workloads under default-deny with explicit allows (ties back to `dependencies`).
- **Requirements:** **R-IDP-02** (protect OIDC-capable and bare UIs), **R-IDP-06** (group-based
@@ -388,6 +438,12 @@ platform must render, and cites the requirement each mapping satisfies. Changing
## Cross-field invariants the renderer / L0-L1 tests rely on
1. **kind ↔ source:** container⇒`image`, vm⇒`vmTemplate` (schema-enforced).
2. **auth ↔ groups:** `oidc`/`forward-auth` ⇒ non-empty `groups` (schema-enforced; R-IDP-06).
2a. **auth ↔ apiRoutes:** a non-empty `auth.apiRoutes` ⇒ `auth.mode: forward-auth`
(schema-enforced). It renders onto the per-app oauth2-proxy sidecar, and no sidecar exists for
`oidc`/`native`/`none`, so declaring it elsewhere would be a **silent no-op** — the operator
believes the app's XHRs now answer 401 and nothing changed. Every entry must be anchored at
`^/` (schema-enforced **and** enforced in `catalog/render.py`, so a breadth mistake is caught by
`render-golden` even on a run where the schema stage did not execute).
3. **default-deny is unconditional:** every namespace gets the deny baseline regardless of
`dependencies`; `dependencies` only *adds* allows (R-NET-01/02). The same holds for
`inClusterClients`: it adds one namespace+port ingress allow and never relaxes the baseline.
+50 -3
View File
@@ -2,7 +2,7 @@
"$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.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 Line truncated
"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.11.0 (adds optional `optional` to env.valueFrom.secretKeyRef and env.valueFrom.configMapKeyRef -- the k8s EnvVarSource field, emitted verbatim. Without it a secretKeyRef was ALWAYS hard: a missing Secret meant the pod would not start at all. That is the right default and stays the default, but it cannot express a value the workload is designed to run without. Added for plex, whose TLS material is issued by cert-manager INTO ITS OWN NAMESPACE and therefore cannot exist before the app wave that creates that namespace -- the Certificate and the Deployment land in the same reconcile, so on a cold bootstrap the Secret is briefly absent while a DNS01 challenge completes. Hard refs there would have made the media server unstartable until a certificate issued, and `apps-wave-0` is `wait: true`, so a failed or slow issuance would have STALLED THE WHOLE APP TIER -- the same hazard catalog/plex.yaml already documents for its GPU resource. With `optional: true` Plex starts regardless, its postStart hook logs that the certificate is not installed, and the next start after issuance converges. Renders nothing when absent, so every existing app stays byte-identical). 1.10.0 (adds optional `availability` for the container kind, one member: `availability.replicas`. It is AVAILABILITY INTENT, not a scheduling primitive -- it names no Kubernetes object, no topology key and no budget. 1 (the default) renders a Deployment exactly as before, so every pre-existing app stays byte-identical; >1 means the service must survive the loss of a single node, and the renderer satisfies that with a StatefulSet whose longhorn volumes become per-replica volumeClaimTemplates (a longhorn PVC is ReadWriteOnce and cannot attach on two nodes, so a multi-replica Deployment is UNSCHEDULABLE, not merely suboptimal), REQUIRED podAntiAffinity on kubernetes.io/hostname (preferred would let every replica land on one node and deliver none of the intended tolerance while reporting healthy), podManagementPolicy Parallel, a PodDisruptionBudget of minAvailable 1, and an `up` SLI that follows the rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever and can never fire). Added for pihole, where LAN DNS -- a critical network service -- was one pod on one node: measured 2026-10-04, losing the node running it meant roughly 6-10 minutes with no LAN resolution (tolerationSeconds 300 plus node-NotReady detection plus a Longhorn RWO force-detach). Deliberately NOT usable until a service's state is reproducible from git, because a replica with a fresh volume otherwise comes up serving but unconfigured -- for pihole that prerequisite was done first (decisions/2026-10-04-pihole-declare-state.md). Capped at 5: a count above the node count can never schedule under the anti-affinity this implies. Mechanism documented in schemas/renderer-contract.md `### availability`). 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 pluLine truncated
"type": "object",
"additionalProperties": false,
"required": ["name", "source"],
@@ -69,7 +69,8 @@
"required": ["name", "key"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"key": { "type": "string", "minLength": 1 }
"key": { "type": "string", "minLength": 1 },
"optional": { "type": "boolean", "description": "Tolerate the key being ABSENT: the env var is simply unset instead of the pod refusing to start (kubelet CreateContainerConfigError). Default false, which is the pre-1.11.0 behaviour. Use ONLY where the workload degrades gracefully without the value, and say so at the call site -- a silently-empty credential is usually worse than a pod that will not start." }
}
},
"configMapKeyRef": {
@@ -78,7 +79,8 @@
"required": ["name", "key"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"key": { "type": "string", "minLength": 1 }
"key": { "type": "string", "minLength": 1 },
"optional": { "type": "boolean", "description": "Tolerate the key being ABSENT: the env var is simply unset instead of the pod refusing to start (kubelet CreateContainerConfigError). Default false, which is the pre-1.11.0 behaviour. Use ONLY where the workload degrades gracefully without the value, and say so at the call site -- a silently-empty credential is usually worse than a pod that will not start." }
}
}
}
@@ -139,6 +141,21 @@
"supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." }
}
},
"availability": {
"type": "object",
"description": "Optional availability intent for the container kind: how many replicas of this workload must be running. INTENT ONLY -- it names no Kubernetes object and no scheduling primitive. How the platform satisfies it (workload kind, per-replica volumes, node spreading, disruption budget) is catalog/render.py's business, documented in schemas/renderer-contract.md `### availability`. Absent => replicas 1 and a Deployment, so every pre-existing app renders byte-identical. Ignored for kind=vm (a VM's availability is a different problem with different primitives).",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"replicas": {
"type": "integer",
"description": "How many replicas must be running. 1 (default) renders a Deployment exactly as before. >1 declares that the service must survive the loss of a single node; the renderer then emits a StatefulSet with per-replica volumeClaimTemplates, required per-node anti-affinity, podManagementPolicy Parallel and a PodDisruptionBudget. Only declare >1 for a service whose on-disk state is reproducible from git -- a replica with a fresh volume comes up serving whatever it can bootstrap itself, and nothing else.",
"minimum": 1,
"maximum": 5,
"default": 1
}
}
},
"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).",
@@ -324,6 +341,16 @@
"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": []
}
}
},
@@ -471,6 +498,26 @@
}
}
},
{
"$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": {