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
+27 -6
View File
@@ -83,22 +83,43 @@ def prune_images(argv):
def check_images(_argv):
"""Report drift in both directions between the database and the files."""
"""Report drift in both directions between the database and the files.
Presence alone is not health: a file can be there and be truncated. Each
row records the byte count it was stored with, so comparing sizes catches a
partial write or a botched restore that a filename check sails past.
"""
import os
with db.session() as conn:
rows = conn.execute("SELECT token, mime, part_id FROM part_images").fetchall()
rows = conn.execute(
"SELECT token, mime, part_id, bytes FROM part_images"
).fetchall()
expected = {os.path.basename(db.image_path(r["token"], r["mime"])): r for r in rows}
present = set(os.listdir(db.IMAGE_DIR))
missing = sorted(set(expected) - present)
orphans = sorted(present - set(expected))
wrong_size = []
for name in sorted(set(expected) & present):
row = expected[name]
actual = os.path.getsize(os.path.join(db.IMAGE_DIR, name))
if actual != row["bytes"]:
wrong_size.append((name, row, actual))
print(f"{len(expected)} referenced photo(s), {len(present)} file(s) on disk")
for name in missing:
print(f" MISSING FILE part {expected[name]['part_id']} {name}")
print(f" MISSING FILE part {expected[name]['part_id']} {name}")
for name, row, actual in wrong_size:
print(f" WRONG SIZE part {row['part_id']} {name} "
f"expected {row['bytes']} bytes, found {actual}")
for name in orphans:
print(f" orphan file {name}")
# A referenced photo with no file is data loss; a stray file is only clutter.
return 1 if missing else 0
print(f" orphan file {name}")
if not missing and not wrong_size:
print(" photos are consistent with the database")
# A referenced photo that is absent or damaged is data loss; a stray file is
# only clutter, and prune-images deals with it.
return 1 if (missing or wrong_size) else 0
def list_image_files(_argv):