Resolve the backup volume from the container, and verify photo sizes

Three audit items on the backup path.

The script matched volume names by pattern and took the first hit, so a stale
or restored volume could be backed up instead of the live one — and every
verification step would then faithfully confirm the wrong database. It now asks
the container what is mounted at /data and refuses ambiguity. Tested against a
decoy volume that the old pattern would have matched first.

check-images treated a photo as healthy if a file with the right name existed,
so a truncated or partially restored file passed. It compares each file against
the byte count its row records now; a one-byte stand-in for a 123KB photo is
reported as WRONG SIZE and exits non-zero. The backup runs the same check
against the stopped volume and exits 2 when the source was already damaged —
still writing the archive, because a faithful copy of imperfect data is worth
having, but saying so.

The restart trap was installed after the app had already been stopped, so an
interrupt in between could leave the service down with nothing to bring it
back. The trap goes in first now, covers INT and TERM as well as EXIT, and
records whether the container was running beforehand so a backup of an
already-stopped app leaves it stopped.

Verified on the live host: healthy source exits 0, damaged source exits 2 with
the archive still written and verified, decoy volume correctly ignored, service
answering immediately afterwards, and the test rows removed.

Checks go from 290 to 294.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-25 10:12:45 -04:00
parent ccb3da51ad
commit 8d87f1c13d
4 changed files with 143 additions and 45 deletions
+21 -5
View File
@@ -62,7 +62,7 @@ Then open http://127.0.0.1:8123.
## Tests
```sh
.venv/bin/python -m tests.test_api # 214 checks, in-process
.venv/bin/python -m tests.test_api # 217 checks, in-process
.venv/bin/python -m tests.test_concurrency # 33 checks, against a real uvicorn
```
@@ -179,8 +179,21 @@ So the script stops the app for the few seconds the copy takes. With no process
attached, `parts.db` and its `-wal`/`-shm` sidecars are a consistent set (which
is also why `cp parts.db` alone is wrong on a running database — recent commits
may still be sitting in the WAL) and the images directory cannot move
underneath. It then verifies that every photo the database references is
actually present in the archive, and fails loudly if not.
underneath.
It resolves the data directory by asking the container what is mounted at
`/data`, rather than matching volume names by pattern — a stale or restored
volume with a similar name would otherwise be backed up instead, and every
check would then faithfully verify the wrong database. Ambiguity is refused
rather than guessed at.
Then it verifies three things: that the source data is internally consistent,
that every photo the database references is present in the archive, and that
the archived database opens and passes SQLite's `integrity_check`. Exit status
is `0` when all of that holds, `1` if the archive is incomplete or unusable,
and `2` if the archive is fine but the *source* was already damaged — you still
get the backup in that case, because a faithful copy of imperfect data is worth
having; you just get told.
Restore by stopping the container and unpacking the archive into the volume.
@@ -191,8 +204,11 @@ docker exec parts python -m app.admin check-images # drift, in both direction
docker exec parts python -m app.admin prune-images # delete files nothing references
```
`check-images` exits non-zero only for a referenced photo whose file is missing
— that is data loss, where a stray file is just clutter. `prune-images` ignores
`check-images` compares each file against the byte count its row records, so a
truncated or partially restored photo is caught rather than waved through on
the strength of its filename. It exits non-zero for a referenced photo that is
missing or the wrong size — that is data loss, where a stray file is just
clutter. `prune-images` ignores
anything less than an hour old, because an upload writes its file before
inserting its row and a young orphan is indistinguishable from an upload still
in flight; `--all` overrides that and is only safe with the app stopped.