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]>
178 lines
13 KiB
YAML
178 lines
13 KiB
YAML
---
|
|
# 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: []
|