Plex has been transcoding in software on hardware that can do it in the iGPU. This opts its ServiceDefinition into the `hardware` capability from #27 and re-renders. catalog/plex.yaml gains four lines of YAML and the comment that explains them: hardware: gpu: intel-quicksync which renders exactly two things into apps/wave-0/plex.yaml and nothing else — a `dri` hostPath volume (/dev/dri, type: DirectoryOrCreate) and a read-write /dev/dri volumeMount on the app container. Whole-tree render diff: 6 added lines, one file. The comment records what is not obvious from the four lines: the device exists on ALL THREE prod nodes with i915 bound (so no nodeSelector is rendered and none is wanted); why `supplementalGroups` would be INERT rather than a hardening (the image's cont-init hook adds the plex 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); and why `DirectoryOrCreate` is load-bearing (apps-wave-0 is wait:true with apps-wave-1 depending on it, so a failed mount would stall the whole app tier for a feature that is not). THE FEATURE IS INERT UNTIL A HUMAN CLICKS A BOX, and that is the part this commit is really about. Mounting the device does not enable hardware transcoding; Plex's `HardwareAcceleratedCodecs` preference does, and it is ABSENT from Preferences.xml — the toggle has never been on. It cannot be automated: a bounded grep across the running image's entire init surface (/etc/cont-init.d, /etc/services.d, /etc/fix-attrs.d, /plex-common.sh, /usr/local/bin) found ZERO PLEX_PREFERENCE* support, and the six setPref keys the image does write are all unrelated. So runbooks/plex-quicksync.md is the procedure, registered in the configMapGenerator (a runbook in git with no generator entry is reviewed, merged, and still a 404) and indexed in runbooks/README.md. It covers the GitOps/manual boundary, the toggle and the real preference keys (including the widely-cited one that does not exist and the upstream misspelling nobody should "fix"), verification via `Transcode (hw)` on the Plex dashboard — stated plainly as the ONLY proof, because Plex renders no ServiceMonitor and an alert could never fire — the device-cgroup failure to expect first with its Intel-device-plugin fallback, and the floating-`:latest` caveat. Stacked on #27 (feat/sd-hardware-capability), which is open and unmerged; that branch's commit is in this range by design and this must merge after it. NOT VERIFIED, and the handoff says so at length: hardware transcoding has not been observed working. Whether containerd's device cgroup lets a non-privileged pod open() a bind-mounted char device is an open question that only deploying settles, and the Plex-side toggle was still unset at the time of writing. A green gate proves the manifest renders; it proves nothing about transcoding. Gate (pinned toolbox, worktree): rc 3 — UNDETERMINED, not green. Passed: decisions-log, lint, l0, render-golden, l1, approval-policy. Skipped (the known local ceiling): spec-immutability (no sibling spec repo), gitea-settings (no SETTINGS_API_TOKEN), conformance (CLUSTER_AVAILABLE unset, so L2/L3 did not run). No fourth skip, nothing failed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
178 lines
12 KiB
Markdown
178 lines
12 KiB
Markdown
# Alert runbooks — home-network-k3s prod
|
||
|
||
This is the index page served at `https://home.keithjackson.dev/runbooks/`.
|
||
|
||
Every alert rule in this platform carries an annotation of the form
|
||
|
||
runbook_url: "https://${BASE_DOMAIN}/runbooks/<name>"
|
||
|
||
46 of them, over 35 names. Until this directory existed, that path returned **HTTP 404** on the
|
||
live cluster: every alert that reached a human handed them a dead link at the exact moment they needed
|
||
help. These pages are what makes the link resolve.
|
||
|
||
## How to run the commands in these runbooks
|
||
|
||
`kubectl` does not work on the operator workstation. The toolchain lives only in the pinned dev
|
||
container, and the host's `k3s-oidc` context hangs on a browser prompt once its token expires — that hang
|
||
is not the cluster being down.
|
||
|
||
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'
|
||
|
||
Always pass `--context k3s-prod`. `dev.sh` merges BOTH clusters into one kubeconfig and picks a
|
||
current-context per invocation, and **that default has already changed once** — k3s-dev until dev was
|
||
decommissioned on 2026-09-13, k3s-prod now. A command that names its context still means the same thing
|
||
next month; one that relies on the default does not, and the failure is silent. `CLUSTER=prod
|
||
infra/devcontainer/dev.sh run kubectl ...` selects it explicitly, and every invocation prints a banner
|
||
naming the cluster — read it. Do not run two `dev.sh` invocations at once; they share one container.
|
||
|
||
**Use fully-qualified resource names, always.** Three CRDs on this cluster register the kind `Backup` —
|
||
`longhorn.io/v1beta2`, `postgresql.cnpg.io/v1`, `velero.io/v1` — and the bare short name resolves to
|
||
Longhorn's, which holds zero objects. `kubectl get backup -A` therefore prints **"No resources found"** on
|
||
a cluster holding 6 Velero and 27 CNPG backups (verified on prod 2026-09-13). Acting on that false
|
||
negative is how a CLI quirk becomes real data loss. Write `backups.velero.io`,
|
||
`backups.postgresql.cnpg.io`, `clusters.postgresql.cnpg.io`, `settings.longhorn.io`,
|
||
`volumes.longhorn.io`, `nodes.longhorn.io`.
|
||
|
||
Merging a PR into `main` **is** the deploy; there is no other apply step. `main` is protected, so a
|
||
direct push is rejected — durable changes go through a branch, a PR, and a green gate.
|
||
|
||
## Alerts you should not trust the silence of (measured 2026-09-13)
|
||
|
||
The Longhorn and cert-manager scrapes were ENABLED on 2026-09-13, so the Longhorn row below is no
|
||
longer inert — and re-keying the two robustness rules the same day fixed a SECOND inertness the
|
||
scrape had left in place (the metric is one-hot over a `state` label, not a 0–3 enum). Take the
|
||
Longhorn row as history; the rest of this table still holds, and Flux-controller and Hubble/Cilium
|
||
rule groups still evaluate against nothing and **cannot fire**:
|
||
|
||
| Missing scrape job | Inert alerts |
|
||
|---|---|
|
||
| Longhorn | `LonghornVolumeDegraded`, `LonghornVolumeFaulted`, `LonghornDiskAllocationHigh`, `LonghornDiskUnschedulable` |
|
||
| cert-manager | `CertificateExpiringSoon`, `CertificateExpiryImminent`, `CertificateNotReady` |
|
||
| 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 |
|
||
|
||
Each affected runbook says so on its own page and gives the by-hand equivalent. See `targets-down`.
|
||
|
||
## The runbooks
|
||
|
||
### Nodes
|
||
- **`node-down`** — a node's node-exporter is unscrapable; the node may be gone, or its kubelet may have
|
||
stopped posting status while the host stays up (the 2026-09-07 shape).
|
||
- **`node-io`** — disk I/O pressure/starvation. The etcd WAL shares a partition with Longhorn replicas;
|
||
this is the alert that would have called the 2026-09-07 outage and did not exist at the time.
|
||
- **`node-memory`** — memory pressure. Includes why `SwapTotal == 0` is not evidence swap is off.
|
||
- **`node-disk`** — a node filesystem filling up. `/data` is the one that matters.
|
||
- **`node-cpu`** — CPU PSI and run-queue depth, and how to tell CPU contention from I/O contention.
|
||
|
||
### Storage
|
||
- **`longhorn-degraded`** — a volume has lost redundancy. Normal after any reboot, and the rebuild is the
|
||
expensive part. Freeze/thaw commands here.
|
||
- **`longhorn-faulted`** — no usable replica. Data at risk; stop before anything destructive.
|
||
- **`longhorn-capacity`** — a node's Longhorn disk running out of room for replicas (allocation, not PVC fill; for PVC fill see `pvc-capacity` / `monitoring-capacity`).
|
||
- **`pvc-capacity`** — PVC filling up / almost full / predicted to fill. These rules work.
|
||
- **`monitoring-capacity`** — Prometheus or Loki running out of disk; don't let the monitor blind itself.
|
||
|
||
### GitOps and updates
|
||
- **`flux-reconcile`** — a Flux resource stuck at Ready=False. Which tier decides the blast radius.
|
||
- **`flux-suspended`** — reconciliation turned off and forgotten. Drift becomes silent.
|
||
- **`update-rollback`** — a HelmRelease failed its health gate and is rolling back.
|
||
- **`image-automation`** — the git write-back engine is failing (feature is disabled here).
|
||
- **`image-policy-stalled`** — an ImagePolicy/ImageRepository cannot resolve a tag.
|
||
|
||
### Certificates
|
||
- **`cert-issuance`** — ACME issuance failing. The DNS-01 self-check is the usual cause.
|
||
- **`cert-expiry`** — a certificate is expiring. Almost always a late symptom of `cert-issuance`.
|
||
|
||
### Networking
|
||
- **`vpn-down`** — the egress VPN tunnel. Explains what the kill-switch actually does, and the silent
|
||
egress-gateway staleness this alert cannot see — which now has its own alerts and page below.
|
||
- **`vpn-egress-stale`** — the tunnel is UP but `egress-class=vpn` pods have no egress: Cilium's cached
|
||
ifindex outlived a reconnect. Also covers the watchdog for that rule going inert.
|
||
- **`denied-flows`** — policy-denied flow spike. The Cilium hairpin/identity regression class.
|
||
|
||
### Backups and DR
|
||
- **`backup-failure`** — a Velero backup failed. The R2 object-tagging incompatibility is documented here.
|
||
- **`backup-partial`** — a Velero backup completed with partial failures, and which skips are deliberate.
|
||
- **`backup-missed`** — no successful Velero backup in 24h.
|
||
- **`cnpg-archiving`** — WAL archiving stuck / no recoverability point. **An availability incident, not
|
||
just a backup one.**
|
||
- **`cnpg-backup-missed`** — no recent CNPG base backup. Velero does not cover these databases.
|
||
- **`cnpg-wal-growth`** — `pg_wal` not being recycled. Never delete WAL by hand.
|
||
- **`cnpg-exporter`** — the CNPG SQL exporter erroring: a blind spot that looks exactly like health.
|
||
- **`cnpg-primary-spread`** — two or more CNPG primaries on one node. Blast radius, not an outage,
|
||
and the rebalance costs ~9s of writes per database — one cluster at a time.
|
||
|
||
### Identity
|
||
- **`keycloak-down`** — the IdP is down. Blast radius, and the break-glass path that still works.
|
||
- **`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.
|
||
|
||
### Operator procedures (no alert links here)
|
||
- **`plex-claim`** — claiming the Plex Media Server. **Claimed 2026-09-29**; the page now only
|
||
applies after a `/config` wipe. The claim token is short-lived — read the countdown on
|
||
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.
|
||
- **`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
|
||
app never saw). Covers the cookie collision between per-app sidecars sharing the default
|
||
`_oauth2_proxy` name on `.home.keithjackson.dev` — which deletes sessions belonging to apps in
|
||
*other* namespaces and is only legible with two logs side by side — the 5-minute access-token
|
||
session with no `--cookie-refresh`, and why an XHR answered with a 302 instead of a 401 loses a
|
||
POST body silently.
|
||
- **`crafty-admin-credential`** — you signed into Keycloak and Crafty *still* asks for a password.
|
||
Expected: Crafty 4 has no OIDC, so its own local login sits behind the oauth2-proxy gate as a second
|
||
factor. The page tells the two layers apart from the sidecar log, and explains that the bootstrap
|
||
password exists **only** on the PVC (`default-creds.txt`, generated once on first boot, never in git).
|
||
Includes the argon2 reset for a lost password, why the 3-strikes lockout cannot fire behind the
|
||
sidecar, and why deleting `crafty.sqlite` to force a new password destroys every server definition.
|
||
|
||
### Virtualisation
|
||
- **`kubevirt-control-plane`** — VM lifecycle operations will fail; running VMs keep running.
|
||
- **`kubevirt-launch-failed`** — a VMI reached the Failed phase. The three k3s-specific launch traps.
|
||
- **`kubevirt-crashloop`** — a virt-launcher pod restarting repeatedly.
|
||
- **`cdi-import-stuck`** — a DataVolume import not progressing. Check node I/O before restarting it.
|
||
|
||
## Editing a runbook
|
||
|
||
Each page is `runbooks/<name>.md` in the `home-network-k3s-impl` repo. Edit it, push to `main`, and Flux
|
||
re-renders the ConfigMap and rolls the two-replica server; kustomize's content hash on the generated
|
||
ConfigMap name is what triggers the roll.
|
||
|
||
Adding a **new** alert with a `runbook_url` requires the matching `runbooks/<name>.md` **and** its
|
||
`<name>=<name>.md` entry in `runbooks/kustomization.yaml`. Both are enforced by
|
||
`conformance/tests/l0-static/alert-runbooks-resolvable`, which runs unconditionally in the merge gate —
|
||
"the file exists in git" and "the URL resolves in the cluster" are different claims, and it checks both.
|
||
|
||
Every page must carry the four operator sections (*What this means here*, *First commands*, *Likely
|
||
causes, most likely first*, *When to escalate or stop*), mention `dev.sh`, and pass `--context k3s-prod`.
|
||
The check enforces that too, because a stub that makes the link resolve while helping nobody is worse than
|
||
the 404 it replaced — it looks fixed.
|
||
|
||
## Why this route has no login
|
||
|
||
Deliberately public. The UIs that declare forward-auth (pihole, jackett, qbittorrent, radarr, sonarr)
|
||
really are closed: enforcement is per-pod, each one's HTTPRoute targeting its oauth2-proxy sidecar on
|
||
:4180 rather than the app port. If
|
||
these pages did too, `keycloak-down` would be unreadable exactly when Keycloak is down,
|
||
`keycloak-db` unreadable when its database is down, and `keycloak-client-secrets` unreadable during the
|
||
precise failure that broke all 12 OIDC clients on 2026-09-06 behind an entirely green cluster. An
|
||
operator who cannot log in is this route's primary reader. The content is operational procedure — metric
|
||
names, thresholds, kubectl invocations, topology — and no secrets. See
|
||
`runbooks/serving/httproute.yaml` for the full reasoning, including how forward-auth is actually
|
||
enforced here and the instruction to leave this route **out** of any Gateway-level ext-authz attachment
|
||
if one is ever introduced.
|
||
|
||
The running narrative of real incidents on this cluster is `DECISIONS-LOG.md`.
|