pdf-quiz-generator/backend/scripts/migrate_uploads_to_s3.py
Daniel d071e5cdc5 feat: serve uploads through storage, and move all 3,852 files to MinIO
Serving went straight to disk with FileResponse, so object storage was
effectively write-only: bytes went to the bucket and were still read from the
volume. `/uploads/{path}` now tries the local file first, then the object,
keeping the existing authorisation and path-confinement checks in front of both.
That is what makes the volume removable at all.

Migration (scripts/migrate_uploads_to_s3.py)
Every file is copied and read back with a SHA-256 comparison before anything is
deleted, and deletion is a separate opt-in flag that refuses to run if a single
file failed to verify. 3,852 files, 853.7 MB, all verified, then removed from the
volume — which now holds 0 files.

A bug this caught in its own first run: verification used `storage_service.load`,
which falls back to the volume, so it compared each local file against itself and
reported 3,852 perfect matches against an empty bucket. `s3_object` reads
strictly from S3 with no fallback, and verification uses that. The fallback is
right for serving and wrong for verifying, and the two now have separate calls.

Proven before deleting: a file removed from the volume still served correctly and
byte-identically from the bucket.

Backups, corrected: borgmatic already covers /var/lib/docker/volumes, so
quiz_minio_data is backed up nightly with 7/4/6 retention — my earlier claim that
MinIO was outside the backup routine was wrong, based on db-backup alone.
Existing archives still hold the old uploads volume, so there is no window in
which these files exist in only one place.

Tests: 131 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:23:34 +02:00

120 lines
4.1 KiB
Python

"""Copy the existing uploads volume into object storage.
Verification order matters: every file is copied and read back *before* anything
is deleted, and deletion only happens when explicitly asked for. A migration that
removes the source before proving the destination is readable has no way back.
docker compose exec backend python -m scripts.migrate_uploads_to_s3
docker compose exec backend python -m scripts.migrate_uploads_to_s3 --apply
docker compose exec backend python -m scripts.migrate_uploads_to_s3 --verify
docker compose exec backend python -m scripts.migrate_uploads_to_s3 --apply --delete-local
"""
import hashlib
import sys
from pathlib import Path
from app.config import settings
from app.services import storage_service
def walk(root: Path):
"""Every file under the uploads directory, keyed by its relative path."""
for path in sorted(root.rglob("*")):
if path.is_file() and not path.name.startswith("."):
yield path.relative_to(root).as_posix(), path
def human(size: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def main():
apply_changes = "--apply" in sys.argv
verify_only = "--verify" in sys.argv
delete_local = "--delete-local" in sys.argv
if not storage_service.using_s3():
print("STORAGE_BACKEND is not 's3'; nothing to migrate into.")
return 1
root = Path(settings.UPLOAD_DIR)
if not root.is_dir():
print(f"No uploads directory at {root}")
return 1
storage_service.ensure_bucket()
files = list(walk(root))
total_bytes = sum(path.stat().st_size for _key, path in files)
print(f" files on the volume : {len(files)} ({human(total_bytes)})")
copied, already, failed, verified, mismatched = 0, 0, [], 0, []
for key, path in files:
data = path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
# Strictly S3: the fallback in `load` would compare a file to itself.
if verify_only or storage_service.s3_object(key) is not None:
stored = storage_service.s3_object(key)
if stored is None:
failed.append(key)
elif hashlib.sha256(stored).hexdigest() != digest:
mismatched.append(key)
else:
verified += 1
already += 1
continue
if not apply_changes:
copied += 1
continue
try:
storage_service.save(key, data)
# Read back before trusting it; a write that cannot be read is not a copy.
stored = storage_service.s3_object(key)
if stored is None or hashlib.sha256(stored).hexdigest() != digest:
mismatched.append(key)
else:
copied += 1
verified += 1
except Exception as error:
failed.append(f"{key}: {error}")
print(f" copied : {copied}")
print(f" already in storage : {already}")
print(f" read back and match : {verified}")
if mismatched:
print(f" MISMATCHED : {len(mismatched)}")
for key in mismatched[:10]:
print(f" {key}")
if failed:
print(f" FAILED : {len(failed)}")
for key in failed[:10]:
print(f" {key}")
if delete_local:
if mismatched or failed:
print("\n Refusing to delete: not every file verified.")
return 1
if not apply_changes:
print("\n --delete-local needs --apply.")
return 1
removed = 0
for key, path in files:
# Belt and braces: prove the object reads before removing the source.
if storage_service.s3_object(key) is not None:
path.unlink()
removed += 1
print(f" local files removed : {removed}")
if not apply_changes and not verify_only:
print("\n Re-run with --apply to copy, then --verify to check.")
return 0
if __name__ == "__main__":
sys.exit(main())