Files
home-network-k3s-impl/runbooks/cnpg-backup-missed.md
T
jakman85andClaude Opus 5 8e017fad91
gate / gate (pull_request) Successful in 1m34s
gate / gate (push) Successful in 1m34s
gate / conformance (pull_request) Has been skipped
gate / conformance (push) Has been skipped
docs(runbooks): the PR flow, not git push origin main -- that instruction is now blocked
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]>
2026-09-29 01:42:35 -05:00

166 lines
10 KiB
Markdown

# cnpg-backup-missed — No recent CNPG base backup for a Postgres cluster
**Alert:** `CNPGNoRecentBaseBackup` (warning, 2h)
**File:** `platform/cnpg/alerts.yaml`, group `platform-health.cnpg`
**Expression:**
```
(time() - max by (namespace, job) (cnpg_collector_last_available_backup_timestamp)) > (36 * 3600)
and max by (namespace, job) (cnpg_collector_last_available_backup_timestamp) > 0
```
## What this means here
The newest completed **base backup** for one Postgres cluster is over 36 hours old — one missed daily run
plus slack. The daily `ScheduledBackup` has silently stopped producing backups, so the PITR window is
ageing out against the `30d` retention policy.
**Nothing else covers this.** The Velero rules in `platform/monitoring/alerts.yaml` watch Velero's own
schedules, and **Velero does not back up these databases** — CNPG owns their DR path, because a Velero
PVC snapshot of a live Postgres is not a backup. So `/runbooks/backup-missed` being clear says nothing
about the databases, and vice versa.
The three Clusters and their staggered base-backup times: **keycloak 01:00**, **nextcloud 02:00**,
**semaphore 04:00** (Velero's own daily runs at 03:00, deliberately between them).
## Two expression details that matter when you read the alert
**The `> 0` guard is load-bearing.** A cluster that has *never* had a backup reports `0`, and
`time() - 0` is about **57 years** — always true. Without the guard this rule would double-notify with
`CNPGNoRecoverabilityPoint`, which is the correct alert for "never had a backup". So this rule means
specifically: *this cluster has had at least one backup, and the newest one is stale.*
**It is aggregated `by (namespace, job)`, not `by (namespace, pod)`** — fixed 2026-09-12 alongside
`CNPGNoRecoverabilityPoint`. `cnpg_collector_last_available_backup_timestamp` has the same primary-only
defect: measured on prod the same day, `keycloak-db-2` = 3.1h old, `nextcloud-db-2` = 2.1h,
`semaphore-db-2` = 0.1h, and **all six replicas = 0**, i.e. `time() - 0` ≈ **56.7 years**. Unlike its
sibling this rule was *not* firing — because the `> 0` guard was the single thing standing between it and
six more permanent false positives, which is far too thin a margin for a DR alarm. Aggregating `pod` away
removes the dependence: `max` picks the primary's timestamp as the cluster's answer (a demoted primary
drops to 0 and can never win the `max`; a fresher backup always beats a staler one).
`job` is the grouping label because the metric carries **no `cnpg.io/cluster` label** — the operator
creates one PodMonitor per Cluster, so prometheus-operator derives `job="<namespace>/<cluster>"`:
`keycloak/keycloak-db`, `nextcloud-db/nextcloud-db`, `semaphore-db/semaphore-db`.
**Severity is `warning` on purpose** — this repo has been burned by a permanently-firing `critical`
(the `VPNTunnelDown` post-mortem), and *"noise on a DR alarm is worse than no alarm at all"*.
## 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
**Fully-qualified names, always.** `kubectl get backup -A` prints **"No resources found"** on this
cluster even with **27 CNPG backups** present: three CRDs 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. Verified on prod 2026-09-13. Concluding "the backups are gone"
from that and acting on it is how a CLI quirk becomes data loss.
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get backups.postgresql.cnpg.io -A \
-o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,CLUSTER:.spec.cluster.name,PHASE:.status.phase,START:.status.startedAt,STOP:.status.stoppedAt --sort-by=.metadata.creationTimestamp | tail -20'
```
Are the schedules there and enabled?
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get scheduledbackups.postgresql.cnpg.io -A \
-o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,SCHEDULE:.spec.schedule,SUSPEND:.spec.suspend,CLUSTER:.spec.cluster.name,LAST:.status.lastScheduleTime'
```
The Cluster's own verdict (and the condition that tells you whether *archiving* is also broken):
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get clusters.postgresql.cnpg.io -A \
-o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,PRIMARY:.status.currentPrimary,ARCHIVING:.status.conditions[?\(@.type==\"ContinuousArchiving\"\)].status,LASTBACKUP:.status.conditions[?\(@.type==\"LastBackupSucceeded\"\)].status,FIRSTPITR:.status.firstRecoverabilityPoint'
```
Why the last attempt failed — the barman error is in the primary's logs:
```
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n keycloak logs keycloak-db-2 -c postgres --tail=200 2>&1 | grep -iE "barman|backup|archive" | tail -30; echo ====; \
kubectl --context k3s-prod -n cnpg-system logs deploy/cnpg-controller-manager --tail=120 2>&1 | grep -iE "backup|error" | tail -20'
```
## Likely causes, most likely first
### 1. The ScheduledBackup is suspended, missing, or unapplied
Check `spec.suspend`, and whether the object exists at all for that Cluster. A `ScheduledBackup` that was
never applied is the "looks done, is absent" failure class this repo keeps finding; verify the Flux tier
that owns it is Ready (`/runbooks/flux-reconcile`) and not suspended (`/runbooks/flux-suspended`).
### 2. The object store is rejecting the write
Same root cause family as `/runbooks/cnpg-archiving`: endpoint, path, region or credentials. Live prod
values to expect are `CNPG_BACKUP_PATH = s3://k3s-prod-backup/barman`,
`S3_BACKUP_ENDPOINT = https://7ce452a1b3d6c86daf2c020edcb82d37.r2.cloudflarestorage.com`,
`S3_BACKUP_REGION = auto`, `BACKUP_CREDENTIALS_SUFFIX = -prod`. **If `ContinuousArchiving` is also
False, fix that first** — a cluster whose archiver is broken has a disk-full countdown running, which is
more urgent than a stale base backup.
### 3. A failed run that nobody noticed
There is precedent: one historical failure, `semaphore-db-daily-20260905130918`. A single failed run does
not clear itself; the next night's run should. If two consecutive nights fail, the cause is
configuration, not luck.
### 4. The primary changed and the new primary cannot write
The backup catalogue is the **primary's** job. After a switchover, a credential or path problem scoped to
one instance shows up here. Check which pod is `currentPrimary` and read that pod's logs, not another's.
### 5. Node or storage trouble during the backup window
Base backups are I/O-heavy and run at 01:00-04:00 alongside Velero. Cross-read `/runbooks/node-io`.
### 6. The metric is missing, not the backup
If `cnpg_collector_last_available_backup_timestamp` has no series, the rule cannot fire. Note that a
**related** blind spot is real and separate: the CNPG SQL exporter can fail while `up` stays 1 — see
`/runbooks/cnpg-exporter`.
## When to escalate or stop
- **A stale base backup narrows, but does not eliminate, recoverability** — provided WAL archiving is
healthy, PITR still works from the older base. That is why this is a warning. If **both** this and
`CNPGNoRecoverabilityPoint` / `CNPGWALArchiveBacklog` are lit, DR for that database is effectively
gone: escalate.
- **Never delete WAL, `.ready` markers, or anything under a Postgres data directory.**
- **Do not delete old CNPG Backup objects to tidy up.** Retention is `30d` and the operator manages it;
deleting a Backup object can orphan objects in R2.
- **Do not trigger a base backup as a reflex** on a cluster whose archiver is failing — it adds write
load to a volume that may already be filling.
- **Be honest about restore:** the CNPG restore path from R2 has not been rehearsed on this cluster
(`dr-restore` is authored and never executed). Free R2 egress makes a rehearsal cheap; it is
outstanding. A restore is an operator operation.
---
Source of truth: `runbooks/cnpg-backup-missed.md`. Index: `/runbooks/`
Records: `platform/cnpg/alerts.yaml` (the 2026-09-12 aggregation-scope fix with its live measurements,
the `> 0` guard rationale, and the staggered schedule).