feat(523): restore reads the backup tar; python -m scribe.restore brings a fresh instance back
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Failing after 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Failing after 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Skipped
The restore route takes the tar as a raw body beside the JSON download. scribe/restore.py reads either format, refuses a database that already has an account (a restore re-creates everything with new ids, so into a live instance it would duplicate rather than replace), has no size limit, and says to restart so search indexes rebuild. An integration test restores a written tar through the real restore_full_backup and checks every id is remapped. docs/backups.md explains the file, why it is uncompressed, both destinations, Fabled Archives as the worked example, and restoring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""Restore a Scribe backup into an empty instance, from the command line.
|
||||
|
||||
python -m scribe.restore /backups/scribe-20261015T020000Z.tar
|
||||
|
||||
This is how a fresh instance is brought back after losing the old one. Run it
|
||||
inside the Scribe container, which already has the database settings and has
|
||||
already migrated the schema on start. It reads either the scheduled backup's
|
||||
tar (services/backup_file.py) or the JSON the admin download produces.
|
||||
|
||||
It refuses a database that already holds an account. A restore re-creates
|
||||
every user, project and record from the file with new ids; into a live
|
||||
instance it would add a second copy of everything rather than replace it.
|
||||
(Settings → Backup & Restore still offers the upload for that case, on
|
||||
purpose and with its own warning.)
|
||||
|
||||
Unlike the upload, nothing limits the file's size here, so this is also the
|
||||
way in for a backup too big for the web upload.
|
||||
|
||||
Afterwards, restart Scribe: search indexes are not part of a backup and are
|
||||
rebuilt from the restored records at startup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
|
||||
def load_backup(path: Path) -> dict:
|
||||
"""The payload in `path`, whichever of the two backup formats it is."""
|
||||
from scribe.services.backup_file import is_archive, read_archive
|
||||
|
||||
with open(path, "rb") as f:
|
||||
if is_archive(f):
|
||||
return read_archive(f)
|
||||
return json.load(f)
|
||||
|
||||
|
||||
async def _account_count() -> int:
|
||||
from scribe.models import async_session
|
||||
from scribe.models.user import User
|
||||
|
||||
async with async_session() as session:
|
||||
return (await session.execute(select(func.count()).select_from(User))).scalar_one()
|
||||
|
||||
|
||||
async def run(path: Path) -> int:
|
||||
from scribe.models.engine import engine
|
||||
from scribe.services.backup import restore_full_backup
|
||||
from scribe.services.backup_file import ArchiveError
|
||||
|
||||
try:
|
||||
try:
|
||||
payload = load_backup(path)
|
||||
except FileNotFoundError:
|
||||
print(f"There is no file at {path}.", file=sys.stderr)
|
||||
return 2
|
||||
except (ArchiveError, ValueError) as exc:
|
||||
print(f"{path} isn't a backup this Scribe can read: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
accounts = await _account_count()
|
||||
if accounts:
|
||||
print(
|
||||
f"This database already holds {accounts} account(s). A restore adds a second "
|
||||
"copy of everything rather than replacing it, so it only runs into an empty "
|
||||
"instance. Start Scribe on an empty database and run this again.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
stats = await restore_full_backup(payload)
|
||||
print(json.dumps({"restored": stats}, indent=2, default=str))
|
||||
print("Restored. Restart Scribe so the search indexes are rebuilt.", file=sys.stderr)
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m scribe.restore",
|
||||
description="Restore a Scribe backup (.tar or .json) into an empty instance.",
|
||||
)
|
||||
parser.add_argument("file", type=Path, help="the backup file")
|
||||
args = parser.parse_args(argv)
|
||||
return asyncio.run(run(args.file))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -59,7 +59,21 @@ async def backup():
|
||||
@admin_bp.route("/restore", methods=["POST"])
|
||||
@admin_required
|
||||
async def restore():
|
||||
data = await request.get_json()
|
||||
"""Restore from an uploaded backup: the JSON download (application/json)
|
||||
or the scheduled backup's tar (sent as the raw body). The upload is held
|
||||
to the app's request-size limit; `python -m scribe.restore` has none, and
|
||||
is the way in for a bigger file or a fresh instance."""
|
||||
if request.mimetype == "application/json":
|
||||
data = await request.get_json()
|
||||
else:
|
||||
import io
|
||||
|
||||
from scribe.services.backup_file import ArchiveError, read_archive
|
||||
|
||||
try:
|
||||
data = read_archive(io.BytesIO(await request.get_data()))
|
||||
except ArchiveError as exc:
|
||||
return jsonify({"error": f"That file isn't a Scribe backup: {exc}"}), 400
|
||||
if not data:
|
||||
return jsonify({"error": "No backup data provided"}), 400
|
||||
|
||||
|
||||
Reference in New Issue
Block a user