feat(6849): the command-line restore uses the same rollback, and the docs say how restore works now
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Successful in 31s

python -m grimoire.restore <file> prints what the restore would change (the
same preview as Settings) and stops. With --yes it runs
backup_job.rollback_to_backup: the current state is saved first, then the
whole instance goes back to the backup. An empty instance takes any full
backup. A live one only takes a backup of its own, and otherwise exits 3 with
the reason. A v1 backup keeps the copy-in path into an empty instance.

docs/backups.md Restoring is rewritten:
- what a restore puts back and removes;
- the same-instance rule;
- the safety backup;
- what survives;
- the web and command-line ways in.

Its S3 key advice now asks for read as well as write when restoring from the
bucket. README, features, architecture and the API reference follow.

Milestone 582, step 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 20:08:31 -05:00
co-authored by Claude Opus 5.5
parent 1f5e963c35
commit 37be3b0bff
6 changed files with 139 additions and 56 deletions
+80 -30
View File
@@ -1,20 +1,26 @@
"""Restore a Grimoire backup into an empty instance, from the command line.
"""Restore a Grimoire backup from the command line.
python -m grimoire.restore /backups/grimoire-20261015T020000Z.tar
python -m grimoire.restore /backups/grimoire-20261015T020000Z.tar --yes
This is how a fresh instance is brought back after losing the old one. Run it
inside the Grimoire 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.
Run it inside the Grimoire container, which already has the database settings
and has migrated the schema on start. It reads the scheduled backup's tar
(services/backup_file.py) or the JSON the admin download produces, and nothing
limits the file's size.
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.)
It does what Settings → Restore does (services/backup.py replace_from_backup):
- **A new, empty instance** takes the backup as it is.
- **A live instance** rolls back to a backup of its own: everything goes back
to the moment the backup was taken, for every user.
- A backup from another instance, or one made before backups recorded their
instance, only goes into an empty instance.
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.
Without --yes it only says what would change. With --yes it first saves the
current state as a backup (to the backup destination, or DATA_DIR/pre-restore),
then restores. Everyone is signed out afterwards.
A backup in the oldest (v1) format is copied into an empty instance with new
ids instead, as before.
Afterwards, restart Grimoire: search indexes are not part of a backup and are
rebuilt from the restored records at startup.
@@ -48,9 +54,39 @@ async def _account_count() -> int:
return (await session.execute(select(func.count()).select_from(User))).scalar_one()
async def run(path: Path) -> int:
from grimoire.models import engine
async def _restore_v1(payload: dict) -> int:
from grimoire.services.backup import restore_full_backup
accounts = await _account_count()
if accounts:
print(
f"This database already holds {accounts} account(s). A backup in the oldest "
"format is copied in with new ids rather than replacing anything, so it only "
"goes into an empty instance. Start Grimoire 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))
return 0
def _print_preview(preview: dict) -> None:
taken = preview.get("exported_at") or "an unknown date"
if preview["mode"] == "rollback":
print(f"This rolls the whole instance back to {taken}, for every user. Anything "
"made since is removed, edits since are undone, records deleted since come back.")
else:
print(f"This instance has no records yet; it takes the backup from {taken} as it is.")
for count in preview["counts"]:
print(f" {count['kind']:<24} now {count['now']:>7} after {count['after']:>7}")
async def run(path: Path, *, yes: bool = False) -> int:
from grimoire.models import engine
from grimoire.services import backup_job
from grimoire.services.backup import RestoreRefused, replace_preview
from grimoire.services.backup_file import ArchiveError
try:
@@ -63,20 +99,29 @@ async def run(path: Path) -> int:
print(f"{path} isn't a backup this Grimoire 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 Grimoire 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 Grimoire so the search indexes are rebuilt.", file=sys.stderr)
return 0
if (payload.get("version") or 1) < 2:
code = await _restore_v1(payload)
else:
try:
_print_preview(await replace_preview(payload))
if not yes:
print("Nothing was changed. Run it again with --yes to restore.",
file=sys.stderr)
return 4
result = await backup_job.rollback_to_backup(payload)
except RestoreRefused as exc:
print(str(exc), file=sys.stderr)
return 3
safety = result["safety_backup"]
print(json.dumps({"restored": result["restored"], "safety_backup": safety},
indent=2, default=str))
print(f"The state it replaced is saved as {safety['file']} in {safety['where']}.",
file=sys.stderr)
code = 0
if code == 0:
print("Restored. Restart Grimoire so the search indexes are rebuilt; "
"everyone signs in again.", file=sys.stderr)
return code
finally:
await engine.dispose()
@@ -84,11 +129,16 @@ async def run(path: Path) -> int:
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m grimoire.restore",
description="Restore a Grimoire backup (.tar or .json) into an empty instance.",
description=(
"Restore a Grimoire backup (.tar or .json): into an empty instance, or "
"roll this instance back to a backup of its own."
),
)
parser.add_argument("file", type=Path, help="the backup file")
parser.add_argument("--yes", action="store_true",
help="restore; without it, only say what would change")
args = parser.parse_args(argv)
return asyncio.run(run(args.file))
return asyncio.run(run(args.file, yes=args.yes))
if __name__ == "__main__":