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]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-10-01 21:56:07 -05:00
1 parent ba79a1b59b
commit bce101d03f
9 files changed
+446 -24

No files matched your search

+28 -10
View File
@@ -51,18 +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_*`) — measured 2026-09-30 | no authentication alerting exists at all for crafty, jackett, pihole, qbittorrent, radarr, sonarr |
| 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 the least obvious, because the scrape is not merely missing —
there is **nothing to scrape**. `count by (__name__) ({__name__=~"oauth2_proxy.*"})` on prod returns
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 in the `oauth2-proxy` namespace. The six per-app gate sidecars run without
`--metrics-address`, so their metrics listener never starts; they declare no `metrics` container port;
and no ServiceMonitor or PodMonitor selects them. Any rule written against `oauth2_proxy_*` for those
namespaces would be inert on arrival. `OAuth2ProxyGateUnobserved`
(`platform/oauth2-proxy/auth-visibility-alerts.yaml`) alerts on that gap itself, using
`kube_pod_container_info` and `up` — two families that *are* scraped — and clears when a gate gains a
real scrape. See `oauth2-proxy-auth-failures`.
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`.
+80 -7
View File
@@ -42,12 +42,14 @@ signature of the session being **re-established over and over instead of held**
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:** the per-app gate sidecars export **no metrics**. They
run without `--metrics-address`, so oauth2-proxy's metrics listener never starts; they declare no
`metrics` port; and no ServiceMonitor or PodMonitor selects them. Only the shared
`oauth2-proxy` namespace instance is scraped, and it is not in the data path for any of these
apps. So there is nothing to graph and nothing that could have alerted. The logs below are not a
convenience — until that changes they are the **only** record that this class of failure happened.
**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
@@ -60,9 +62,49 @@ with an explicit context.
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\"})"'
"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:
@@ -105,6 +147,37 @@ ratio in the tens means sessions are not surviving. Measured on prod 2026-09-30
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