CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 17s
CI and images / frontend-build (push) Successful in 19s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Failing after 2m26s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Settings → Maintenance → Backups gains "Restore from the bucket": pick one of the bucket's dated dumps, type its confirmation, and FabledCurator saves the current database to the bucket, replaces it with the dump, migrates it to this version, then fetches every image and attachment back to the path its row records. Each file is checked against its sha256 on arrival and moved into place only then; a file already on disk is left alone, so "Fetch missing files" repairs a library that lost files without touching the database. - services/backup_restore.py: list/find/fetch dumps, migrate, keep this install's destination (the dump's sealed secret can't be opened here), library_files work list, restore_file (one fetch per sha, copies for duplicate paths, refuses paths outside /images). - restore_offsite_task: the database phase, then 45-minute file chunks with a heartbeat; a failed load leaves an empty migrated database and says why. The dump's own run row is marked ok, other runs then going "Interrupted". - backup_s3.get_file: sha/size check on arrival, ObjectMissing for a 404, download pacing. - backup_service: every dump leaves the credential table's rows out; restore_db reports pg_restore's last line, stops at the first error, ends other connections and waits at most 60 s for locks. - Backups and orphan repair wait while a restore runs; the stall sweep judges restores by heartbeat; restore runs are pruned to 20. - CLI: python -m backend.app.scripts.restore list|database|files. - README: backup and restore section; logins are in no backup. - Tests: a round trip through an in-process S3 bucket (tests/fake_s3.py) with real pg_dump/pg_restore, plus file, run, API and get_file cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.9 KiB
Python
120 lines
4.9 KiB
Python
"""Restore FabledCurator from its off-site bucket, from a shell in the
|
|
container. The Backup screen does the same; this is for an install whose page
|
|
you can't reach yet, or for scripting a rebuild.
|
|
|
|
python -m backend.app.scripts.restore list
|
|
python -m backend.app.scripts.restore database --dump latest --yes
|
|
python -m backend.app.scripts.restore files
|
|
|
|
On a brand-new install with no destination saved, name it on the command
|
|
line: --endpoint, --bucket and --access-key, with the secret key in the
|
|
FC_BACKUP_SECRET_KEY environment variable (or typed at the prompt). It is
|
|
checked by listing the bucket, then saved as this install's destination,
|
|
sealed, exactly as the settings page saves it.
|
|
|
|
`database` replaces the database with the chosen dump here, then hands the
|
|
files to the worker; their progress shows on the Backup screen. `files`
|
|
fetches only what is missing from disk and leaves the database alone.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import getpass
|
|
import os
|
|
import sys
|
|
|
|
from ..models import ImportSettings
|
|
from ..services import backup_destination, backup_restore
|
|
from ..services.backup_s3 import S3Error
|
|
from ..tasks._sync_engine import sync_session_factory
|
|
|
|
|
|
def _human(n: int) -> str:
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
if n < 1000 or unit == "TB":
|
|
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
n /= 1000
|
|
return str(n)
|
|
|
|
|
|
def _destination(args) -> object:
|
|
"""The target to restore from: the one named on the command line, saved
|
|
first, or the one already saved."""
|
|
session_factory = sync_session_factory()
|
|
if args.endpoint or args.bucket or args.access_key:
|
|
fields, why = backup_destination.clean_fields({
|
|
"endpoint": args.endpoint, "bucket": args.bucket,
|
|
"access_key": args.access_key, "region": args.region,
|
|
})
|
|
if fields is None:
|
|
raise SystemExit(f"Destination not usable: {why}.")
|
|
secret = os.environ.get("FC_BACKUP_SECRET_KEY") or getpass.getpass("Secret key: ")
|
|
target = backup_destination.target_for(fields, secret.strip() or None)
|
|
if target is None:
|
|
raise SystemExit("Name the address, bucket, access key and secret key.")
|
|
check = backup_destination.check_target(target)
|
|
print(check["message"])
|
|
if not check["ok"]:
|
|
raise SystemExit(1)
|
|
with session_factory() as session:
|
|
settings = ImportSettings.load_sync(session)
|
|
limit = settings.backup_upload_limit_mbps
|
|
backup_restore.keep_destination(session, target, limit)
|
|
print("Saved as this install's off-site destination.")
|
|
return target
|
|
with session_factory() as session:
|
|
target = backup_destination.saved_target(ImportSettings.load_sync(session))
|
|
if target is None:
|
|
raise SystemExit("No off-site destination is saved; name one with "
|
|
"--endpoint, --bucket and --access-key.")
|
|
return target
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(prog="python -m backend.app.scripts.restore",
|
|
description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("action", choices=("list", "database", "files"))
|
|
parser.add_argument("--dump", default="latest",
|
|
help="the dump's key from `list`, or latest (the default)")
|
|
parser.add_argument("--yes", action="store_true",
|
|
help="confirm replacing this install's database")
|
|
parser.add_argument("--endpoint")
|
|
parser.add_argument("--bucket")
|
|
parser.add_argument("--access-key")
|
|
parser.add_argument("--region", default=None)
|
|
args = parser.parse_args(argv)
|
|
|
|
try:
|
|
target = _destination(args)
|
|
if args.action == "list":
|
|
for d in backup_restore.list_dumps(target):
|
|
print(f"{d.key} {_human(d.size_bytes)}")
|
|
return 0
|
|
|
|
from ..tasks.backup import restore_offsite_task
|
|
if args.action == "files":
|
|
restore_offsite_task.delay(files_only=True)
|
|
print("Queued: the worker fetches every missing file; watch the Backup screen.")
|
|
return 0
|
|
|
|
dump = backup_restore.find_dump(target, args.dump)
|
|
if not args.yes:
|
|
print(f"This replaces this install's database with {dump.key} "
|
|
f"({_human(dump.size_bytes)}). Run again with --yes to go ahead.")
|
|
return 2
|
|
print(f"Restoring {dump.key}; this takes a while for a large library…")
|
|
result = restore_offsite_task(dump_key=dump.key)
|
|
if result.get("skipped"):
|
|
print(f"Not started: {result['skipped']}.")
|
|
return 1
|
|
print("The database is restored. The worker is fetching the library's files; "
|
|
"watch the Backup screen.")
|
|
return 0
|
|
except S3Error as exc:
|
|
print(f"Couldn't restore: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|