Files
home-network-k3s-impl/runbooks/kustomization.yaml
T
jakman85andClaude Opus 5 fc7ec01628
gate / gate (pull_request) Successful in 1m42s
gate / conformance (pull_request) Has been skipped
feat(obs): make oauth2-proxy auth failures visible — alert on the gap, not on absent metrics
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

102 lines
5.6 KiB
YAML

---
# Runbooks tier — reconciled by its OWN non-gating post-platform Flux Kustomization
# (clusters/base/runbooks.yaml, dependsOn platform-config, wait:false). Same placement and rationale as
# platform/homepage: a leaf web server must never sit in the gating `platform` tier, where a UI's
# unhealth once stalled the whole chain (DECISIONS-LOG: "Headlamp gating stall").
#
# WHY THE MANIFESTS LIVE HERE, ALONGSIDE THE DOCS, RATHER THAN IN platform/runbooks/.
# The markdown is the source of truth and the task is to serve it verbatim, which means the ConfigMap must
# be GENERATED from these files — there must not be a second hand-maintained copy of 34 documents that can
# drift from the originals. `configMapGenerator` does that, and its `files:` paths are resolved relative to
# THIS directory. A `platform/runbooks/kustomization.yaml` would therefore need `../../runbooks/<name>.md`,
# which depends on kustomize load restrictions being relaxed — true for Flux's kustomize-controller,
# refused by a plain `kustomize build`, and so a divergence between what the gate can verify and what the
# cluster does. Making this directory the Flux path removes the question entirely: no `../`, identical
# behaviour locally and in-cluster, one copy of every document. The serving manifests are tucked under
# `serving/` so `runbooks/*.md` stays exactly the flat set of pages the URLs name.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: runbooks
resources:
- serving/namespace.yaml
- serving/nginx-config.yaml
- serving/deployment.yaml
- serving/service.yaml
# default-deny baseline + Cilium gateway-ingress allow (DNS-only egress from the baseline).
- serving/networkpolicy.yaml
- serving/httproute.yaml # apex `/runbooks` prefix (binds the Gateway `https-apex` listener)
configMapGenerator:
# ONE ConfigMap holding every runbook, keyed by the EXACT name the alert annotations use.
#
# THE KEYS ARE EXTENSIONLESS ON PURPOSE. The annotations are `https://${BASE_DOMAIN}/runbooks/<name>`
# with NO `.md`, and this repo must not change them (they are spread across four alerts.yaml files that
# other work is editing). `<key>=<file>` maps `node-down.md` on disk to the key `node-down`, the volume
# mount turns each key into a file under the nginx root, and `/runbooks/node-down` becomes a plain static
# lookup — no rewrite rule, nothing to get subtly wrong at 3am. Drop the `<key>=` and every key would
# carry `.md`, so every alert link would 404 while this file still looked correct.
#
# The generated name carries kustomize's content hash, so editing any runbook rolls the server
# automatically and a stale projection is impossible.
#
# ADDING A RUNBOOK: add the file AND its line here. The L0 check
# conformance/tests/l0-static/alert-runbooks-resolvable asserts both halves, because a file in git with
# no generator entry is reviewed, merged, apparently done — and still a 404 in the cluster.
- name: runbooks-content
options:
labels:
app.kubernetes.io/name: runbooks
files:
# The index, served at /runbooks/ (nginx `try_files /runbooks/README`).
- README=README.md
- backup-failure=backup-failure.md
- backup-missed=backup-missed.md
- backup-partial=backup-partial.md
- cdi-import-stuck=cdi-import-stuck.md
- cert-expiry=cert-expiry.md
- cert-issuance=cert-issuance.md
- cnpg-archiving=cnpg-archiving.md
- cnpg-backup-missed=cnpg-backup-missed.md
- cnpg-exporter=cnpg-exporter.md
- cnpg-primary-spread=cnpg-primary-spread.md
- cnpg-wal-growth=cnpg-wal-growth.md
# Operator procedure, not alert-linked (Crafty's local login is a second factor behind
# the Keycloak gate and its bootstrap password lives only on the PVC).
- crafty-admin-credential=crafty-admin-credential.md
- denied-flows=denied-flows.md
- flux-reconcile=flux-reconcile.md
- flux-rollout=flux-rollout.md
- flux-suspended=flux-suspended.md
- image-automation=image-automation.md
- image-policy-stalled=image-policy-stalled.md
- keycloak-client-secrets=keycloak-client-secrets.md
- keycloak-db=keycloak-db.md
- keycloak-down=keycloak-down.md
- kubevirt-control-plane=kubevirt-control-plane.md
- kubevirt-crashloop=kubevirt-crashloop.md
- kubevirt-launch-failed=kubevirt-launch-failed.md
- longhorn-capacity=longhorn-capacity.md
- longhorn-degraded=longhorn-degraded.md
- longhorn-faulted=longhorn-faulted.md
- monitoring-capacity=monitoring-capacity.md
- node-cpu=node-cpu.md
- node-disk=node-disk.md
- node-down=node-down.md
- 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 an alert page (no alert links to it — the
# alert-runbooks-resolvable check treats that as advisory). It is served
# because the plex.tv claim window is short and is not a thing to go looking
# for in git: /runbooks/plex-claim has to resolve the moment it is needed.
- plex-claim=plex-claim.md
- pvc-capacity=pvc-capacity.md
- targets-down=targets-down.md
- update-rollback=update-rollback.md
- vpn-down=vpn-down.md
- vpn-egress-stale=vpn-egress-stale.md