Three corrections after rebasing onto main. 1. THE GUARD CAUGHT A REAL ONE ON ITS FIRST CONTACT WITH main. main had gained `CNPGPrimariesCoLocated` (platform/cnpg/alerts.yaml) pointing at /runbooks/cnpg-primary-spread, with no runbook. The check failed the rebase exactly as designed — 46 annotations, 35 names, 34 present — naming both the missing file AND the missing configMapGenerator entry. runbooks/cnpg-primary-spread.md now exists: what co-located primaries actually cost here (blast radius, not an outage, because instances: 3 + required anti-affinity), why the drift is uncorrectable-by-declaration (CNPG has no primary-placement field), the readiness gate that keeps the rule quiet during a real node outage, and the ~9s-per-database switchover rebalance, one cluster at a time. The 2026-09-07 outage in which all three primaries sat on one node is the reason the alert exists at all. 2. WHOLE-REPO SCOPE, NOT A HAND-WRITTEN STAGE. main's refactor replaced ci/l0.sh's per-check "UNCONDITIONAL" stages with a declaration: `conformance.home.k3s/scope: "whole-repo"`, unioned into every gate run by Stage 3b's `--include-scope`. My +38-line Stage 3f is removed (ci/l0.sh is now byte-identical to main) and the annotation is declared instead. Assertion (7) of the check was rewritten to match: it no longer asserts its own path appears in ci/l0.sh — it asserts the test DECLARES the scope and that ci/l0.sh still passes --include-scope, since neither half alone makes the check load-bearing. That matters here because ci/touched-requirements.sh maps platform/monitoring/* to "R-OBS-01 R-OBS-02", not R-OBS-04, and maps platform/kubevirt/* and runbooks/* to nothing at all. 3. A FACTUAL CORRECTION IN ALL 35 RUNBOOKS. Every page claimed `dev.sh` defaults its current-context to k3s-dev. main's `9b4744d fix(devcontainer): dev.sh defaults to prod` made that false, and a runbook that misstates which cluster a command talks to is worse than one that says nothing. The pages now say what is durably true: the default has already changed once, so naming `--context k3s-prod` is what makes a command mean the same thing next month, and the per-invocation banner is there to be read. The check's own failure message was corrected the same way. Re-verified after all three: check green at 374 assertions (46 annotations, 35/35 runbooks present, 35/35 projected), re-applied to prod, and re-probed in-cluster through the Gateway — node-down, keycloak-down, keycloak-client-secrets, cnpg-archiving, longhorn-faulted, vpn-down, node-io and cnpg-primary-spread all HTTP 200 with text/plain; charset=utf-8 and 8.9-12.1 KB of real body; /runbooks/ index 200; an unknown name 404s with a pointer rather than a blank page; and homepage still answers the apex `/`, confirming Gateway API longest-path precedence. Full merge gate: PASS (spec-immutability, lint, l0, render-golden, l1 all green; prom-rules SKIPPED for want of promtool in the image; L2/L3 SKIPPED, no cluster). Co-Authored-By: Claude Opus 5 <[email protected]>
9.3 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 == 3
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 even fire (measured 2026-09-13)
Prometheus on prod scrapes 19 jobs: apiserver, cdi-prometheus-metrics, coredns,
keycloak-discovery, keycloak-service, keycloak/keycloak-db, kube-prometheus-stack-alertmanager,
kube-prometheus-stack-grafana, kube-prometheus-stack-operator, kube-prometheus-stack-prometheus,
kube-state-metrics, kubelet, kubevirt-prometheus-metrics, nextcloud-db/nextcloud-db,
node-exporter, oauth2-proxy, semaphore-db/semaphore-db, velero, velero/node-agent.
There is no Longhorn, cert-manager, Flux-controller or Hubble/Cilium job. Check the series this
rule depends on before you trust its silence:
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 -G --data-urlencode "query=count by (job) (up)" localhost:19090/api/v1/query'
This alert is currently INERT. count(longhorn_volume_robustness) returned 0 series on prod
on 2026-09-13 — Longhorn ships no ServiceMonitor here, so the whole platform-health.longhorn group
has no data. A faulted volume today announces itself as an application outage. That is a known open
defect; treat volumes.longhorn.io as the source of truth, not 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 (git push origin main 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).