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
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:
+80
-30
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user