fix(backup): quiesce, timestamp, encrypt and verify the volume backup - #164
Conversation
`make backup` was seven lines inline in the Makefile, and every defect in #64 followed from that. It wrote backups/<volume>.tar.gz — one fixed name, no timestamp, no rotation — and tar truncates at open(2), so a run that failed had already destroyed the last good backup: the only way to lose a backup was to take one. It hardcoded four volume names and had silently skipped alloy-data since Alloy was added. It ran an unpinned `alpine`. It tarred /prometheus while Prometheus was writing to it. It verified nothing beyond tar's exit status. And it bind-mounted backups/ into a container running as root, so every archive came out root-owned and could not be rotated without sudo. It had never been run here; backups/ did not exist. scripts/backup-volumes.sh replaces it, modelled on backup-firewall.sh: - The volume list, the mount points and the services to stop are DERIVED from compose.yaml. alloy-data was missing because the list was hardcoded; adding a fifth entry would have fixed the symptom and left the mechanism. A volume no service mounts, or one with no sentinel, is a named error rather than a silent skip. - Sets are timestamped directories with a MANIFEST written last. Retention keeps the seven newest complete sets and prunes only after the new one has verified, so a failed run can never delete a good one. Incomplete sets are reported, never deleted. The newest quiesced set is never evicted by --hot runs. - Each archive is age-encrypted (`age -r`, recipient read from .sops.yaml, not sops — sops would hold a gigabyte of TSDB in memory and base64 it). tar writes to stdout and age writes the file as the operator, so the plaintext never touches disk and nothing is root-owned. - Verification is the tarball analogue of backup-firewall.sh's `<pfsense>` grep: it decrypts, reads the whole gzip stream as a tar, and requires the volume's own discriminating sentinel. age -r has no associated data, so the sentinel is the only thing binding a filename to its content — an archive carrying another volume's sentinel is fatal in both modes. - The default stops the owning services for the copy and a trap restarts them on every exit path, Ctrl-C and SIGTERM included. Verified: exit 130 and 143 both leave six services running. --hot skips the stop and marks the set unproven. The archiver image is a profiled service in compose.yaml so it is digest-pinned where this repo pins images. Debian, not Alpine: BusyBox tar cannot report "file changed as we read it", and that exit status is the only mechanical evidence a --hot run has that it wrote a torn archive. It earned that immediately — a --hot run reported three of five volumes torn, where the old recipe would have exited 0 and called it a backup. scripts/restore-volumes.sh is the destructive half, deliberately separate. Every selected archive is decrypted and read end to end before a byte of live data is removed; --dry-run runs exactly that and stops. --from is mandatory, the typed confirmation is the stamp, a hot set needs a second token, a pre-restore snapshot is written with a manifest of its own so rolling back is `--from .pre-restore-<STAMP>`, and it leaves the stack stopped so "it came back up" is never mistaken for "the restore worked". Also: `.PHONY: backup` sat above `backup-firewall:`, so backup-firewall was never phony and backup's declaration was seven lines from its target. And CI's "no decrypted artefact is tracked" loop omitted backups/ while validate.sh checked it — backup-firewall.sh claims both do, so now both do. docs/runbooks/restore-the-stack.md records what was actually proven on 2026-08-29 by restoring each archive into a scratch volume and starting the pinned image against it: Prometheus reported every block healthy, replayed its WAL and answered queries for series months older than the backup; Grafana opened the restored database and performed no migrations; Loki returned log lines from an hour that closed before the backup; and ownership came back as 65534, 10001 and 472. A whole-stack restore has still never been performed, and the runbook says so. Closes #64 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runbook said §3 was a hypothesis until a set had been restored and the whole stack brought up on it. That has now been done, so it says something else. The set was restored into a scratch compose project and all six services started against it. What it established: the TSDB restores and serves, with queries returning series from a day and a week before the stamp; there is a genuine gap after the stamp (nothing at +30m, +1h, +3h) while the same stack scrapes its own targets, so it serves restored history and collects new data with a clean seam between them; grafana.db matches the live database table for table, with the admin row's `created` twelve days before the restore where a fresh provisioning would have stamped it that day; Loki returns lines from an hour that closed before the stamp; nflog and silences come back with their original mtimes; and ownership survives as 65534, 10001 and 472. Two things per-volume testing had not found: - Grafana will not start without internet. GF_INSTALL_PLUGINS makes the background installer contact grafana.com on every start and a failure there is fatal, so it crash-loops even though both plugins are already in the restored volume. On a host that has lost its uplink — an ordinary disaster — the restore succeeds and Grafana still does not come up. Recorded as a CAUTION on the rebuilt-host path. - Alloy replays, and §6b was wrong to call that a failure. Restoring alloy-data puts the log positions back, Alloy re-reads from them and re-ships lines with their original timestamps: about 4,600 duplicates inside the hour before the stamp within four minutes of start-up, then steady across three samples. The restored lines themselves never moved. §6b now measures the replay and names the count that must NOT move instead of asserting one that always does. §4's Grafana check also changed. Under unified storage the provisioned dashboards are not in the legacy dashboard table, and this lab has no annotations, so both suggested checks were vacuous. It now reads the admin's `created` column straight out of grafana.db, which needs no credential and does not depend on the password differing. Two fixes fell out of running it: - Both scripts now honour COMPOSE_PROJECT_NAME, as docker compose itself does. Deriving the volume prefix only from the file's name: key meant that with COMPOSE_PROJECT_NAME set, backup would archive one project's volumes while compose ran another's — and restore would then overwrite the wrong ones. It is also what makes the rehearsal possible without touching the live stack. - restore-volumes.sh no longer writes an empty pre-restore snapshot, or claims to hold "the only copy" of what it replaced, when restoring onto volumes that do not exist yet. The runbook gains a section on rehearsing a restore beside the live stack, including the overlay it needs — container_name is not namespaced by project — and why the rehearsal network must be internal: without it the rehearsal Alertmanager sends to the real notification channels and pings the real dead-man's-switch heartbeat. Still not proven, and now the only gap: restoring in place over the live project and running `make up`, which renders config and hot-reloads and takes no overlay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The whole-stack restore has now been doneThe runbook called §3 a hypothesis. It isn't one any more. A set was restored What it established
Two things per-volume testing had not foundGrafana will not start without internet. Alloy replays, and my §6b was wrong to call that a failure. Restoring §4's Grafana check also changed: under unified storage the provisioned Two fixes that fell out of running it
Safety of the rehearsalThe rehearsal network is Still not provenRestoring in place over the live project and running |
GF_INSTALL_PLUGINS made Grafana's background installer contact grafana.com on every start, and a failure there was fatal to the process: starting module plugin.backgroundinstaller: ... failed to install plugin grafana-piechart-panel@: Get "https://grafana.com/api/plugins/..." Grafana crash-looped, even though both plugins were already present in grafana-data. So a host that had lost its uplink — an entirely ordinary disaster — would restore successfully and then fail to bring Grafana up. Every other service in the stack starts offline. Found by running the whole-stack restore rehearsal in docs/runbooks/restore-the-stack.md. Both plugins are removed rather than migrated to GF_PLUGINS_PREINSTALL, because neither was doing anything: - No dashboard under grafana/dashboards/ uses either panel type. Every panel across the five dashboards is a core type, and the only mention of either plugin anywhere in the repository was the compose line itself. There are no UI-created dashboards to worry about either — the dashboard table is empty in the live database. - grafana-piechart-panel is an Angular plugin, and this Grafana refuses to initialise those. It was downloaded and then rejected on every single start: "Plugin validation failed ... angular plugins are not supported". Grafana has had a built-in piechart since v8 regardless. - GF_INSTALL_PLUGINS is itself deprecated in favour of GF_PLUGINS_PREINSTALL. Removing the declaration fixes all three at once and leaves nothing to migrate. Verified as the task asked, on a scratch project with `internal: true` on the compose network so there is no route off the host, in both cases that matter: fresh volumes grafana healthy, 0 restarts, database ok, whole stack up restored volumes grafana healthy, 0 restarts, database ok, whole stack up with the restored admin row and datasources intact The mechanism is now visible in the logs: "Plugins installed plugins=[]", while Grafana's own bundled apps (pyroscope, exploretraces, metricsdrilldown, lokiexplore, elasticsearch) still register from inside the image. On restored volumes those apps do log "Failed to get plugin info" against grafana.com, but unlike a GF_INSTALL_PLUGINS entry that failure is not fatal — Grafana keeps the version it already has and carries on. The runbook's CAUTION becomes a NOTE: the stack starting with no internet is now a tested property worth knowing during a recovery, rather than a trap. The finding is kept in the rehearsal write-up, in the past tense. One loose end, deliberately not done here: the grafana-piechart-panel directory is still inside the live grafana-data volume, so the Angular rejection is still logged once per start. It is cosmetic, no alert rule matches it, and deleting files out of a production volume is not something to fold into this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #64.
make backuphad five defects in seven inline lines, plus two the issue did notname. It had also never been run on the monitoring host —
backups/did notexist.
What was wrong
tartruncates atopen(2), so a failed run destroyed the last good backupMANIFESTwritten last marks a set complete; pruning runs only after the new set verifiestar's exit status was the whole of the quality controlalloy-datamissing from the hardcoded listcompose.yamlprometheus-datatarred while Prometheus was writing itdocker compose stopby default, with a trap that restarts on every exit path;--hotopts out and marks the set unprovenmake restoreanddocs/runbooks/restore-the-stack.mdbackups/bind-mounted into a root container, so archives wereroot:rootand unrotatabletarwrites to stdout,agewrites the file as the operator;backups/never enters a containerdocker run -v missing_vol:/datacreates an empty volume — a wrong prefix yields five plausible 45-byte archives and exit 0name:incompose.yaml, then every volume inspected up frontAlso:
.PHONY: backupsat abovebackup-firewall:, sobackup-firewallwasnever phony and
backup's declaration was seven lines from its target.Decisions worth reviewing
Quiesce by default. Rejected
--web.enable-admin-apifor the TSDB snapshotendpoint: it also exposes delete-series on a port bound to
0.0.0.0:9090. A~90s nightly gap fits inside the dead-man's-switch heartbeat and is the only
variant the runbook can honestly claim.
age, notsops.sopsholds the whole document in memory and base64s itinto YAML — free for a 6 KB
config.xml, a gigabyte of RSS for a 1 GB TSDB.Recipient still read from
.sops.yaml, so there is still exactly one key.grafana.dbcarries the admin password hash, every API token and everydatasource credential, so this is not optional.
Debian archiver, pinned as a profiled compose service. BusyBox
tarcannotreport
file changed as we read it, and that exit status is the only mechanicalevidence a
--hotrun has that it wrote a torn archive. It earned the choiceimmediately — a
--hotrun reported three of five volumes torn, where the oldrecipe exited 0 and called it a backup. Pinning it in a script instead would be
a pin Dependabot never sees and
make pin-digestsnever touches.The sentinel is load-bearing.
age -rhas no associated data, soloki-data.tar.gz.ageandprometheus-data.tar.gz.ageare interchangeable asfar as
ageis concerned. The sentinel is the only thing binding a filename toits content, so an archive carrying another volume's sentinel is fatal in both
modes.
What was actually tested
Not just "it ran". On 2026-08-29, each archive was extracted into a scratch
volume under a throwaway project name and the pinned image started against it:
error, and answered instant queries for series months older than the backup.
current (710 migrations skipped) and restored its plugin cache.
closed before the backup was taken.
Grafana need in order to write.
Also verified: Ctrl-C and SIGTERM mid-archive both leave six services running
(exit 130 / 143); a corrupted byte fails the AEAD check; swapped archives are
caught by the sentinel;
KEEP=1prunes correctly while keeping the newestquiesced set and leaving incomplete sets alone.
A whole-stack restore has still never been performed, and the runbook's
closing section says so, names what the cheap check does and does not prove, and
specifies the experiment that would close the gap.
Out of scope
stay open; the closing banner points at them.
stop_grace_periodonprometheus/alertmanager—-t 60fixes the backuppath, but
make downstill SIGKILLs at 10s. Stack-wide lifecycle change,its own PR.
backup-firewall.shuses relative paths and only works from the repo root.One drive-by: CI's "no decrypted artefact is tracked" loop omitted
backups/while
validate.shchecked it, andbackup-firewall.sh:21-22claims both do.Now both do.
🤖 Generated with Claude Code