8d87f1c13d
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>
166 lines
6.0 KiB
Python
166 lines
6.0 KiB
Python
"""Recovery CLI.
|
|
|
|
Everyday password changes happen in the UI. This exists for the one case the UI
|
|
cannot help with — a forgotten password — and for the initial handover.
|
|
|
|
docker exec -it parts python -m app.admin set-password
|
|
docker exec parts python -m app.admin show-status
|
|
docker exec parts python -m app.admin check-images
|
|
docker exec parts python -m app.admin prune-images
|
|
"""
|
|
|
|
import getpass
|
|
import sys
|
|
|
|
from . import auth, db
|
|
|
|
|
|
def set_password(argv):
|
|
password = argv[0] if argv else None
|
|
if password is None:
|
|
password = getpass.getpass("New password: ")
|
|
if password != getpass.getpass("Repeat: "):
|
|
print("Passwords did not match.", file=sys.stderr)
|
|
return 1
|
|
problem = auth.password_problem(password)
|
|
if problem:
|
|
print(problem, file=sys.stderr)
|
|
return 1
|
|
with db.session() as conn:
|
|
auth.set_password(conn, password)
|
|
print("Password set. All existing sessions have been signed out.")
|
|
return 0
|
|
|
|
|
|
def clear_password(_argv):
|
|
"""Fall back to the PARTS_PASSWORD environment variable again."""
|
|
with db.session() as conn:
|
|
conn.execute("DELETE FROM settings WHERE key = ?", (auth.PASSWORD_KEY,))
|
|
auth.bump_epoch(conn)
|
|
print("Stored password cleared; PARTS_PASSWORD from the environment is live again.")
|
|
print("All existing sessions have been signed out.")
|
|
return 0
|
|
|
|
|
|
# An upload writes its file before inserting the row, so for a moment a live
|
|
# file legitimately has no row. Pruning anything younger than this would delete
|
|
# a photo out from under a request that is still in flight.
|
|
PRUNE_MIN_AGE_SECONDS = 3600
|
|
|
|
|
|
def prune_images(argv):
|
|
"""Delete image files with no row pointing at them.
|
|
|
|
Only files older than an hour, unless --all is given — and --all is only
|
|
safe with the app stopped, because a file younger than its row is exactly
|
|
what an in-flight upload looks like.
|
|
"""
|
|
import os
|
|
import time
|
|
|
|
ignore_age = "--all" in argv
|
|
with db.session() as conn:
|
|
known = {
|
|
os.path.basename(db.image_path(r["token"], r["mime"]))
|
|
for r in conn.execute("SELECT token, mime FROM part_images").fetchall()
|
|
}
|
|
removed = skipped = 0
|
|
now = time.time()
|
|
for name in os.listdir(db.IMAGE_DIR):
|
|
if name in known:
|
|
continue
|
|
path = os.path.join(db.IMAGE_DIR, name)
|
|
if not ignore_age and now - os.path.getmtime(path) < PRUNE_MIN_AGE_SECONDS:
|
|
skipped += 1
|
|
continue
|
|
os.remove(path)
|
|
removed += 1
|
|
print(f"{removed} orphaned image file(s) removed; {len(known)} referenced file(s) kept.")
|
|
if skipped:
|
|
print(f"{skipped} skipped as too recent to be certain they are orphans "
|
|
f"(stop the app and re-run with --all to include them).")
|
|
return 0
|
|
|
|
|
|
def check_images(_argv):
|
|
"""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, 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}")
|
|
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}")
|
|
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):
|
|
"""Every filename the database expects to exist. Used by the backup script."""
|
|
import os
|
|
|
|
with db.session() as conn:
|
|
for r in conn.execute("SELECT token, mime FROM part_images ORDER BY id").fetchall():
|
|
print(os.path.basename(db.image_path(r["token"], r["mime"])))
|
|
return 0
|
|
|
|
|
|
def show_status(_argv):
|
|
import os
|
|
|
|
with db.session() as conn:
|
|
stored = auth.stored_hash(conn)
|
|
images = conn.execute("SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b "
|
|
"FROM part_images").fetchone()
|
|
print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var")
|
|
print("session epoch :", auth.current_epoch(conn))
|
|
print("auth enabled :", auth.auth_enabled())
|
|
print("images :", f"{images['n']} rows, {images['b'] / 1024 / 1024:.1f} MB",
|
|
f"({len(os.listdir(db.IMAGE_DIR))} files in {db.IMAGE_DIR})")
|
|
return 0
|
|
|
|
|
|
COMMANDS = {"set-password": set_password, "clear-password": clear_password,
|
|
"show-status": show_status, "prune-images": prune_images,
|
|
"check-images": check_images, "list-image-files": list_image_files}
|
|
|
|
|
|
def main(argv=None):
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
if not argv or argv[0] not in COMMANDS:
|
|
print("usage: python -m app.admin {" + "|".join(COMMANDS) + "}", file=sys.stderr)
|
|
return 2
|
|
db.init()
|
|
return COMMANDS[argv[0]](argv[1:])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|