Skip to content

fix(backup): quiesce, timestamp, encrypt and verify the volume backup - #164

Merged
Gerrrt merged 4 commits into
mainfrom
fix/backup-volumes-verified
Aug 29, 2026
Merged

fix(backup): quiesce, timestamp, encrypt and verify the volume backup#164
Gerrrt merged 4 commits into
mainfrom
fix/backup-volumes-verified

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #64.

make backup had five defects in seven inline lines, plus two the issue did not
name. It had also never been run on the monitoring host — backups/ did not
exist.

What was wrong

# Defect Fix
1 Fixed filename; tar truncates at open(2), so a failed run destroyed the last good backup Timestamped set directories; MANIFEST written last marks a set complete; pruning runs only after the new set verifies
2 Nothing verified the result — tar's exit status was the whole of the quality control Decrypt, read the whole stream as a tar, require the volume's own discriminating sentinel
3 alloy-data missing from the hardcoded list The volume list, mount points and services to stop are derived from compose.yaml
4 prometheus-data tarred while Prometheus was writing it docker compose stop by default, with a trap that restarts on every exit path; --hot opts out and marks the set unproven
5 No restore target, no runbook make restore and docs/runbooks/restore-the-stack.md
6 backups/ bind-mounted into a root container, so archives were root:root and unrotatable tar writes to stdout, age writes the file as the operator; backups/ never enters a container
7 docker run -v missing_vol:/data creates an empty volume — a wrong prefix yields five plausible 45-byte archives and exit 0 Project name derived from name: in compose.yaml, then every volume inspected up front

Also: .PHONY: backup sat above backup-firewall:, so backup-firewall was
never phony and backup's declaration was seven lines from its target.

Decisions worth reviewing

Quiesce by default. Rejected --web.enable-admin-api for the TSDB snapshot
endpoint: 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, not sops. sops holds the whole document in memory and base64s it
into 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.db carries the admin password hash, every API token and every
datasource credential, so this is not optional.

Debian archiver, pinned as a profiled compose service. 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 the choice
immediately — a --hot run reported three of five volumes torn, where the old
recipe exited 0 and called it a backup. Pinning it in a script instead would be
a pin Dependabot never sees and make pin-digests never touches.

The sentinel is load-bearing. age -r has no associated data, so
loki-data.tar.gz.age and prometheus-data.tar.gz.age are interchangeable as
far as age is concerned. The sentinel is the only thing binding a filename to
its 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:

  • Prometheus reported every block healthy, replayed its WAL in 1.26s without
    error, and answered instant queries for series months older than the backup.
  • Grafana connected to the restored database, found the schema already
    current (710 migrations skipped) and restored its plugin cache.
  • Loki returned label values and real firewall log lines from an hour that
    closed before the backup was taken.
  • Ownership survived: 65534, 10001 and 472 — what Prometheus, Loki and
    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=1 prunes correctly while keeping the newest
quiesced 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

One drive-by: CI's "no decrypted artefact is tracked" loop omitted backups/
while validate.sh checked it, and backup-firewall.sh:21-22 claims both do.
Now both do.

🤖 Generated with Claude Code

Gerrrt and others added 2 commits August 29, 2026 06:55
`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>
@Gerrrt

Gerrrt commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

The whole-stack restore has now been done

The runbook called §3 a hypothesis. It isn't one any more. A set was restored
into a scratch compose project and all six services were brought up on it, with
§4 run against the result including the negative assertions.

What it established

Claim Evidence
The TSDB restores and serves Every block healthy, WAL replayed in ~1s, count(up) returned 12 at stamp−1d and stamp−7d
The restore actually happened result:[] at stamp+30m, +1h and +3h — a genuine gap — while the same stack scraped 12 targets live
grafana.db is intact Table-for-table match against the live DB; admin row created=2026-08-17, twelve days before the restore
Loki serves the restored store Range query over an hour that closed before the stamp returned suricata/filterlog lines from morpheus
Alertmanager state came back nflog and silences present with their original 06:41 mtimes
Ownership survives 65534 / 10001 / 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 — it crash-loops, even though both plugins are already in the restored
volume. On a host that has lost its uplink, which is an ordinary disaster, the
restore succeeds and Grafana still does not come up. Now a > [!CAUTION] on the
rebuilt-host path. Worth its own issue: GF_INSTALL_PLUGINS is also deprecated
in favour of GF_PLUGINS_PREINSTALL, and I did not want to change stack startup
behaviour inside a backup PR.

Alloy replays, and my §6b was wrong to call that a failure. Restoring
alloy-data puts the log positions back, so Alloy re-reads from them and
re-ships lines carrying their original timestamps — ~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 aren't in the legacy dashboard table, and this lab has no
annotations, so both checks I'd suggested were vacuous. It now reads the admin's
created column straight out of grafana.db — no credential needed, and it
doesn't depend on the password differing.

Two fixes that fell out of running it

  • Both scripts now honour COMPOSE_PROJECT_NAME, as compose itself does.
    This was a real bug: deriving the 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's also what makes the rehearsal possible.
  • restore-volumes.sh no longer writes an empty pre-restore snapshot, or claims
    to hold "the only copy" of what it replaced, when the target volumes don't
    exist yet.

Safety of the rehearsal

The rehearsal network is internal: true. That is the argument, not a detail —
without it the rehearsal Alertmanager would send to the real notification
channels and ping the real dead-man's-switch heartbeat, and the rehearsal
snmp-exporter would poll production devices. The live stack was untouched
throughout and is still up. The runbook now carries the overlay and the
reasoning.

Still not proven

Restoring in place over the live project and running make up — which
renders config and hot-reloads, and takes no overlay. Smaller than the gap this
closed, but not zero.

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>
@Gerrrt
Gerrrt merged commit 1b52574 into main Aug 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

make backup overwrites its own last-good archive, and nothing verifies it

1 participant