feat(backups): assert restic CONTENT, and add the host that was never checked

Operator ruling 2026-09-22 (relayed via svos-dev): have the freshness check
assert snapshot content rather than REST-server reachability, so the green
light is a statement about DATA rather than about a daemon.

⚠ FIRST, A CORRECTION TO MY OWN REPORT. I ran `grep -ic restic` against
backup-freshness-alert.sh -- the WRAPPER -- got 0, and told the operator that
nothing on the fleet verified restic. Wrong. check-backup-freshness.sh has
always checked restic snapshot ages, for seven hosts. I grepped one file of a
two-file tool and generalised, which is the identical error a peer had just
made with dev-backup, made while correcting them.

THE REAL GAP was narrower and still real: the NH3 host list read
`irv-ml1 nh3-docker` and omitted nh3-dev -- the repo holding every Claude Code
session transcript, althing routes, hermes run history and Miranda's
conversation. /volume1/Backup/restic/nh3-dev/ has always existed and always
been written nightly; it was simply never enumerated, so a total failure of the
one repo nobody could reconstruct would have gone unreported indefinitely.

THE CONTENT ASSERTION, built on svos-dev's ladder (list proves the repo
answers; ls proves the index; check proves structure; RESTORE proves the bytes
come back):
  - CONJUNCTIVE: latest snapshot newer than MAX_AGE_H AND the probe path
    present AND a restore returns non-zero bytes. "A snapshot exists containing
    X" is satisfied by a three-month-old one; "the latest is recent" is
    satisfied by an empty one. Age alone was the old problem; content alone is
    the same problem rotated.
  - IT RESTORES. Metadata is what survives the failures worth fearing -- a
    pruned or partially-written repo can list a path whose blobs are gone.
    ~493KB from ~/.local/state/svos, sub-second. Large paths are not restored:
    a repo that returns one file will return others.
  - IDENTITY: the timer runs as lkraven, which has no NOPASSWD sudo on nh3-dev,
    so the probe hops through infra-ops@localhost. /etc/restic is root-only by
    design.

⚠ restic ls ALWAYS PRINTS A HEADER LINE, matched or not. A path absent from the
repo returns 1 line; a real one returned 6. Counting with `grep -c .` read the
header as a hit, so the ABSENT case fell through and reported "blobs gone" --
telling an operator the repository was corrupt when the truth was a mistyped
path. Now `grep -c '^/'`.

TESTED BY MAKING IT FAIL, because a check only ever seen passing is untested:
bogus probe path -> "absent from snapshot"; BACKUP_MAX_AGE_HOURS=1 -> "12h old
(>1h)"; healthy -> snapshot id, age, entries, bytes restored.
This commit is contained in:
vh
2026-09-22 13:33:58 -07:00
parent a4cf2ba0dc
commit ba60fda16c
2 changed files with 114 additions and 19 deletions
+36 -18
View File
@@ -177,30 +177,48 @@ the stop→umount→rm-ghost→remount→start variant.
## Known gaps / TODO
### ⚠ NOTHING VERIFIES THAT RESTIC EVER RUNS (found 2026-09-22, open)
### ✅ restic content assertion (2026-09-22, CLOSED)
`scripts/backup-freshness-alert.sh` contains **zero** references to restic:
⚠ **I reported this gap wrongly first.** I ran `grep -ic restic` against
`backup-freshness-alert.sh` — the **wrapper** — got 0, and told the operator
nothing on the fleet verified restic. The actual checking lives in
`check-backup-freshness.sh`, which had always checked restic snapshot ages for
seven hosts. Grepping one file of a two-file tool and generalising is the same
error a peer had just made with `dev-backup`, made while correcting them.
```sh
grep -ic restic scripts/backup-freshness-alert.sh # -> 0
```
**The real gap was narrower and still real:** the host list read
`irv-ml1 nh3-docker` and **omitted nh3-dev** — the repo holding every Claude
Code transcript, althing routes, hermes history and Miranda's conversation.
`/volume1/Backup/restic/nh3-dev/` had always existed and always been written;
it was simply never enumerated, so a total failure of the one repo nobody could
reconstruct would have gone unreported indefinitely.
It checks PBS guest snapshot ages, and it pings the rest-servers for liveness
(`rest-server-nh3: up (401)`). **That confirms the server answers. It says
nothing about whether a snapshot was ever written to it.**
Both are fixed. nh3-dev is in the list, and a **content assertion** now runs per
the operator ruling of 2026-09-22 (assert snapshot content, not REST-server
reachability):
So if resticprofile stopped, the password file broke, or every run failed, the
checker would go on printing `RESULT: all backups fresh` — correctly by its own
definition — indefinitely. Restic holds the entire home directory on nh3-dev;
PBS holds the VM images. The layer with the granular data is the unwatched one.
- **Conjunctive**: latest snapshot newer than `MAX_AGE_H` **AND** the probe path
present **AND** a restore returns non-zero bytes. Age alone was the old
check's problem; content alone is the same problem rotated.
- **It restores.** Listing proves the *index*; only a restore proves the bytes
come back, and metadata is exactly what survives a pruned or partially-written
repo. ~493 KB from `~/.local/state/svos`, sub-second. The large paths are not
restored: a repo that returns one file will return others, one that cannot is
broken for everything.
- **Identity**: the timer runs as `lkraven` (no NOPASSWD sudo here), so the probe
hops through `infra-ops@localhost`, which has it. The credentials under
`/etc/restic` are deliberately root-only.
This is the same shape as two other instruments found the same day: a tool that
enumerates what is fine has selected against its own subject. Here the light is
not merely blind — it is **actively reassuring** about a system it cannot see.
⚠ **`restic ls` always prints a header line**, matched or not — a path absent
from the repo returns 1 line, a real one returned 6. Counting with `grep -c .`
read the header as a hit and made the ABSENT case fall through to *"blobs
gone"*, telling an operator the repository was corrupt when the truth was a
mistyped path. Count `grep -c '^/'`.
Fix when taken: check restic snapshot **age per host**, not endpoint liveness,
and carry an expected-paths list so "covered by a job that has not run in a
month" stops rendering identically to "covered".
**Tested by making it fail**, because a check only ever seen passing is
untested: a bogus probe path reports *absent*; `BACKUP_MAX_AGE_HOURS=1` reports
*too old*; the healthy path reports the snapshot id, age, entry count and bytes
restored.