main became a protected branch on 2026-09-29, so the direct push that 37 of 39 runbooks instructed is REJECTED. The wording was merely stale before; it is now actively wrong, and it is the line an operator reads mid-incident. Every occurrence now describes the real path: push a branch, open a PR, merge once the gate is green -- that merge is the deploy. 35 files shared one identical block; flux-reconcile.md, README.md and plex-claim.md each said it their own way and were corrected individually. plex-claim.md is rewritten around the delivery loop and records that THE SERVER IS NOW CLAIMED (PlexOnlineToken present in /config), so PLEX_CLAIM is a permanent no-op and the page only applies after a /config wipe. Adds infra/plex/claim-ship.sh, which drives branch -> commit -> push -> PR -> gate -> merge -> reconcile -> pod roll -> verdict with a stopwatch. Three steps are easy to lose under time pressure and two are SILENT when skipped: --with-source (without it the Kustomization applies the previously-fetched revision), the mandatory rollout restart (only the ciphertext changes, so the pod template is byte-identical and nothing restarts itself), and the feat/ branch prefix (act_runner capacity is 1, and a prefix inside gate.yml's push trigger fires a redundant run that serialises ahead of the PR run). Drops the "~4-minute TTL" the page asserted in four places. It was uncited, and upstream pms-docker documents no expiry at all. The countdown on plex.tv/claim is the authority. Measured loop: ~2m45s. DECISIONS-LOG carries the PM handoff: both halves of the 2026-08-31 CI claim were false, the stale-.runner root cause, the four-deep Flux dependency cascade, and the correction that a skipped Gitea job reports success rather than hanging. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
9.7 KiB
longhorn-faulted — A Longhorn volume is FAULTED (data at risk, volume offline)
Alert: LonghornVolumeFaulted (critical, 1m)
File: platform/monitoring/alerts.yaml, group platform-health.longhorn
Expression: longhorn_volume_robustness{state="faulted"} == 1 (one-hot over state, NOT a 0-3 enum)
What this means here
Faulted means Longhorn has no usable replica. The volume is offline; the workload's writes are
failing. This is the most serious storage state on the cluster and the for: 1m is deliberate — there
is no benign transient version of this.
Understand what is and is not recoverable before you touch anything:
- The volumes on this cluster are 3-replica with hard node anti-affinity, so faulted normally means two or three replicas are gone at once — two nodes lost, or one node lost while another volume's rebuild had not finished, or a disk that ate its replica.
- Standbys and replicas are not backups. Recorded verbatim for the databases and true for all of
this: "Standbys replicate corruption and deletions faithfully; only the Barman archive is a
backup." For a CNPG volume (
keycloak-db,nextcloud-db,semaphore-db) the real recovery path is the object-store archive, not Longhorn. For everything else it is Velero. - If this is a
longhorn-vmvolume (RWX Block,migratable: true) the VM is down, not just the disk. If it islonghorn-scratch(single replica, CDI scratch space) losing it is expected and harmless — re-run the import.
Verify the alert can fire (measured 2026-09-13, and it was inert TWICE)
This rule was inert for two independent reasons, both fixed on 2026-09-13. Either alone meant a faulted volume — the most serious storage state on this cluster — alerted nobody, and announced itself only as an application outage:
- No scrape.
count(longhorn_volume_robustness)returned 0 series; the chart'smetrics.serviceMonitor.enableddefaulted false. Fixed inplatform/longhorn/helmrelease.yaml. - Wrong metric shape. With the series present the rule STILL could not fire. It read
longhorn_volume_robustness == 3on the assumption of a 0–3 enum; on longhorn 1.12.1 the metric is one-hot over astatelabel with values 0/1 only. Measured on prod the same day:count(longhorn_volume_robustness)= 96 (24 volumes × 4 states) andcount(longhorn_volume_robustness == 3)= empty. Fixed by keying on the label.
Both directions are now pinned offline by platform/monitoring/tests/longhorn.test.yaml. Check the
live series before trusting this alert's silence:
infra/devcontainer/dev.sh run kubectl --context k3s-prod -n monitoring exec \
prometheus-kube-prometheus-stack-prometheus-0 -c prometheus -- \
/bin/promtool query instant http://localhost:9090 \
'count by (state) (longhorn_volume_robustness)'
Four states, one entry per volume each, is healthy wiring. Empty means the scrape has broken and this
alert is inert again — treat volumes.longhorn.io as the source of truth, never this alert's silence.
Running the commands in this runbook
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. Every command below runs as:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'
Always pass --context k3s-prod explicitly. dev.sh merges BOTH clusters into one kubeconfig and
picks a current-context per invocation, and that default has already changed once — it was k3s-dev
until dev was decommissioned on 2026-09-13, and is k3s-prod now. A command that names its context means
the same thing next month as it does today; one that relies on the default does not, and the failure is
silent (it answers confidently about the other cluster). CLUSTER=prod infra/devcontainer/dev.sh run kubectl ... selects it explicitly, and every invocation prints a banner naming the cluster it is about
to talk to — read it. Do not run two dev.sh invocations at once; they share one container and
interfere.
USE FULLY-QUALIFIED RESOURCE NAMES, ALWAYS. Three CRDs on this cluster register the kind Backup:
longhorn.io/v1beta2, postgresql.cnpg.io/v1 and velero.io/v1. The bare short name resolves to
Longhorn's, which holds zero objects — so kubectl get backup -A prints "No resources found" on
a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09-13. Reading that as
"the backups are gone" and acting on it turns a false negative into real data loss. Write
backups.velero.io, backups.postgresql.cnpg.io, clusters.postgresql.cnpg.io,
settings.longhorn.io, volumes.longhorn.io, nodes.longhorn.io.
Mutating kubectl (apply, patch, annotate, rollout) and flux reconcile are blocked for
agents here: every durable change goes through git. main is a protected branch — a direct push
is rejected. A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
First commands
Stop and take inventory before changing anything. Which volume, which workload, which replicas exist:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n longhorn-system get volumes.longhorn.io \
-o custom-columns=NAME:.metadata.name,ROBUST:.status.robustness,STATE:.status.state,NODE:.status.currentNodeID | grep -Ev "healthy"'
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n longhorn-system get replicas.longhorn.io \
-o custom-columns=NAME:.metadata.name,VOL:.spec.volumeName,NODE:.spec.nodeID,STATE:.status.currentState,FAILED:.spec.failedAt | sort -k2'
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get pv -o custom-columns=PV:.metadata.name,CLAIM:.spec.claimRef.namespace/.spec.claimRef.name,SC:.spec.storageClassName | grep <volume-name>'
What recovery material exists — fully-qualified names, or you will be told there is nothing:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get backups.velero.io -A; echo; \
kubectl --context k3s-prod get backups.postgresql.cnpg.io -A; echo; \
kubectl --context k3s-prod -n longhorn-system get backups.longhorn.io'
kubectl get backup -A prints "No resources found" on this cluster even when six Velero and 27 CNPG
backups exist. Never make a recovery decision from the short name.
Node and disk state:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get nodes; kubectl --context k3s-prod -n longhorn-system get nodes.longhorn.io -o wide'
Likely causes, most likely first
1. Two nodes were unavailable at once
Usually a second reboot or cordon while the first node's rebuilds were still running. Remediation: bring the nodes back. Longhorn will reattach if the replica data survived on disk — which is why you must not delete replicas before checking.
2. A disk failed and took its replica with it
k3s-prod-1's original 2011 SSD had 61,440 reallocated sectors before it was replaced; the dev-era
Seagate had 448 current-pending plus 448 offline-uncorrectable. Check SMART on the node holding the
failed replica (/runbooks/node-io has the command). If the disk is bad, the replica is gone — the
path forward is restore, not repair.
3. All replicas were evicted or deleted
Check whether a disk was disabled, a node removed from Longhorn, or a replicas.longhorn.io object
deleted. staleReplicaTimeout: 30 (minutes) means Longhorn eventually discards replicas it considers
stale.
4. It is a single-replica volume by design
longhorn-scratch is numberOfReplicas: 1. Losing it is not an incident — delete the DataVolume and
re-import.
Remediation, in order
- Do not delete the volume, the PVC, or any replica. A faulted volume with a stale replica is recoverable; a deleted one is not.
- If a node is down, restore the node (
/runbooks/node-down). That is the highest-probability fix. - If Longhorn shows a replica with data but marked failed, an operator can salvage it from the Longhorn UI (Volume → Salvage) — this is the documented path for "all replicas failed but the data is still there".
- If the volume backs a CNPG database, do not try to salvage Postgres files. Recover the Cluster
from the Barman archive (
/runbooks/cnpg-archivingfor how to confirm the archive is real first). - Otherwise restore from Velero. Note honestly:
dr-restoreis authored and has never been executed (conformance.home.k3s/never-executed), and the R2 restore path has not been rehearsed. Expect to be the first person to run it, and read/runbooks/backup-failurefor the R2 plugin caveats before you rely on a backup's metadata.
When to escalate or stop
- Get the operator now. This is a critical, data-at-risk alert; it is not a runbook-and-carry-on situation. Everything above is diagnosis, and steps 3–5 are operator actions.
- Stop before any destructive command. Specifically: no
delete pvc, nodelete volumes.longhorn.io, nodelete replicas.longhorn.io, no touching files under/data/longhorn. - Do not reboot anything while a volume is faulted.
- If the affected volume is
keycloak-db, expect a full SSO outage as a knock-on: kubectl OIDC, Grafana, Headlamp, Gitea and forward-auth all depend on it. The certificate-based kubeconfig is an independent break-glass path and still works.
Source of truth: runbooks/longhorn-faulted.md. Index: /runbooks/
Records: DECISIONS-LOG.md (drive failures 2026-08-22 and 2026-09-07; dr-restore never executed).