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]>
10 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-04 | fix/oauth2-sidecar-metrics-scrape-netpol | The forward-auth gate sidecars' /metrics was NetworkPolicy-blocked — the renderer now opens 44180 to ns monitoring |
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 onobservability.metrics. It has to match the condition the scrape keys on:podmonitor.yamlselects pods by thehomelab.keithjackson.dev/auth-mode: forward-authpod label and nothing else, so a target exists iff the sidecar exists. Keying onobservability.metricswould letmetrics: falseproduce 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 siblingapps-wave-0/1tiers, 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,lastScrapeDurationexactly10.0s— the configuredscrapeTimeout. Hitting the timeout precisely means packets dropped, not refused. - The listener is fine. jackett's
oauth2-proxycontainer carries--metrics-address=0.0.0.0:44180and a named portmetrics: 44180. - The deny is universal. All six namespaces (
jackett radarr sonarr qbittorrent pihole crafty):default-denywithingresskey absent,nIngress=0; the only ingress CNP allowance is 4180fromEntities: [ingress]. Zero namespaces had any-allow-monitoring-scrapeobject. - 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'sfor: 10m. A 15-dayquery_rangeshows 1 episode per namespace, not many. Alertmanager:group_by: [alertname, namespace],repeat_interval: 12honemail-warning→ 5 TargetDown groups = 10 emails/day, plus 6OAuth2ProxyGateUnobservedgroups = 12/day, ~22/day total. 0 silences. - The re-render is deterministic and the diff is exactly the intended one.
apps/is byte-identical torender.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, whichapps/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) andegress-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);l1went FAIL -> pass once the goldens were regenerated. The gate's own words: "Stages that did not run: gitea-settings" — skipped for want ofGITEA_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-waveswas not run (it re-onboardsvm-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, soup == 1for those targets is UNVERIFIED — it is a prediction from the measured drop signature, not an observation. After merge, confirmup{container="oauth2-proxy"}goes to 1 for all 6 targets and that the 11 alerts resolve.send_resolved: trueis set, so expect ~11 resolved emails. - That the CNP
endpointSelectormatches pihole's pods. It selectsapp.kubernetes.io/name: pihole, identical to the shape ofpihole-allow-gateway-ingresswhich demonstrably works for 4180 — so this is inference from a working sibling, not a measurement of the new object. craftyis still unobserved and will keep firingOAuth2ProxyGateUnobserved. It is hand-managed underplatform/crafty, outside the renderer, and has no--metrics-addressat 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_AVAILABLEunset, so L2/L3 did not run here either — the gate proves the L0/L1 offline subset only). - Why
OAuth2ProxyGateUnobservedstarted at 2026-10-04 06:44 rather than 10-02 with TargetDown. Alertmanager did not restart (TargetDown'sstartsAtis still 10-02) and the rule'sforis 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.