feat(6848): Settings restore rolls back, from the destination or a file, after a preview
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python lint (push) Successful in 1s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m42s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Build & push image (push) Canceled after 22s

The web restore no longer copies a backup in beside the live data.

- GET /api/admin/restore/sources lists the backups at the backup destination,
  newest first.
- POST /api/admin/restore/preview says what a restore would do, changing
  nothing: fresh or rollback, when the backup was taken, and counts now and
  after per kind. A backup that cannot apply here gets a 409 with the reason.
- POST /api/admin/restore?confirm=rollback runs backup_job.rollback_to_backup:
  safety backup, replace, then search rebuilt in the background.

Each takes ?source=<name> (a backup at the destination) or an upload. The
destination path exists because uploads are held to the request-size limit,
and a real instance's backup is far over it. backup_s3.get_file downloads
from a bucket. backup_destination.list_backups/open_backup only accept names
the scheduled backup writes, so a request cannot reach any other file.

Settings -> Data -> Restore from Backup opens RestoreBackupDialog. The dialog:
1. Pick a backup at the destination or a file.
2. Read the preview.
3. Type "roll back".
4. Run it. The dialog then says where the replaced state was saved, and sends
   the user to sign in again.

Closes #6840. Milestone 582, step 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 20:06:07 -05:00
co-authored by Claude Opus 5.5
parent 34f8d7060b
commit 1f5e963c35
12 changed files with 657 additions and 80 deletions
+100 -21
View File
@@ -17,7 +17,6 @@ from grimoire.services.auth import (
from grimoire.services.backup import (
export_full_backup,
export_user_backup,
restore_full_backup,
)
from grimoire.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from grimoire.services.logging import get_logs, get_log_stats, log_audit
@@ -57,31 +56,111 @@ async def backup():
)
async def _restore_payload() -> tuple[dict | None, tuple | None]:
"""The backup a restore request names: `?source=<file>` at the backup
destination, or the uploaded file (the JSON download as
application/json, or the tar as the raw body; held to the request-size
limit, which is why the destination is the way in for a large one).
Returns (payload, None) or (None, an error response)."""
import io
import tempfile
from pathlib import Path
from grimoire.services import backup_destination as dest
from grimoire.services.backup_file import ArchiveError, read_archive
source = request.args.get("source")
try:
if source:
config = await dest.load_config()
secret, _ = await dest.load_secret()
with tempfile.TemporaryDirectory(prefix="grimoire-restore-") as staging:
path = await dest.open_backup(config, secret, source, Path(staging))
def load() -> dict:
with open(path, "rb") as f:
return read_archive(f)
data = await asyncio.to_thread(load)
elif request.mimetype == "application/json":
data = await request.get_json()
else:
data = read_archive(io.BytesIO(await request.get_data()))
except dest.DestinationError as exc:
return None, (jsonify({"error": str(exc)}), 400)
except ArchiveError as exc:
return None, (jsonify({"error": f"That file isn't a Grimoire backup: {exc}"}), 400)
if not isinstance(data, dict) or not data:
return None, (jsonify({"error": "No backup data provided"}), 400)
return data, None
@admin_bp.route("/restore/sources", methods=["GET"])
@admin_required
async def restore_sources():
"""The backups at the backup destination, newest first, to restore from
without uploading one."""
from grimoire.services import backup_destination as dest
config = await dest.load_config()
secret, _ = await dest.load_secret()
try:
backups = await asyncio.wait_for(dest.list_backups(config, secret), _BACKUP_CHECK_TIMEOUT)
except dest.DestinationError as exc:
return jsonify({"backups": [], "error": str(exc)})
except (TimeoutError, asyncio.TimeoutError):
return jsonify({"backups": [], "error": (
f"The destination didn't answer within {int(_BACKUP_CHECK_TIMEOUT)} seconds."
)})
return jsonify({"backups": [{"name": b.name, "size_bytes": b.size_bytes} for b in backups]})
@admin_bp.route("/restore/preview", methods=["POST"])
@admin_required
async def restore_preview():
"""What restoring this backup would do, changing nothing: fresh or
rollback, when it was taken, and the counts now and after. 409 with the
reason when it can't be restored here."""
from grimoire.services.backup import RestoreRefused, replace_preview
data, error = await _restore_payload()
if error:
return error
try:
return jsonify(await replace_preview(data))
except RestoreRefused as exc:
return jsonify({"error": str(exc)}), 409
@admin_bp.route("/restore", methods=["POST"])
@admin_required
async def restore():
"""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 grimoire.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
"""Put the whole instance back to a full backup (services/backup.py
replace_from_backup), after backing up what it replaces. `?confirm=rollback`
is required: this removes everything made since the backup, for every
user. Every session ends, the caller's included; search is rebuilt in the
background."""
from grimoire.services import backup_job as job
from grimoire.services.backup import RestoreRefused
from grimoire.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 Grimoire backup: {exc}"}), 400
if not data:
return jsonify({"error": "No backup data provided"}), 400
stats = await restore_full_backup(data)
if request.args.get("confirm") != "rollback":
return jsonify({"error": "Restoring replaces the whole instance; confirm it first."}), 400
data, error = await _restore_payload()
if error:
return error
try:
result = await job.rollback_to_backup(data)
except RestoreRefused as exc:
return jsonify({"error": str(exc)}), 409
except job.BackupBusy:
return jsonify({"error": "A backup is running. Restore once it has finished."}), 409
job.start_reindex_after_restore()
uid = get_current_user_id()
await log_audit("restore", user_id=uid, username=g.user.username, ip_address=client_ip(), details={"stats": stats})
return jsonify({"status": "ok", "stats": stats})
await log_audit(
"restore", user_id=uid, username=g.user.username, ip_address=client_ip(),
details={"mode": result["mode"], "exported_at": result.get("exported_at"),
"safety_backup": result["safety_backup"], "stats": result["restored"]},
)
return jsonify({"status": "ok", **result})
@admin_bp.route("/users", methods=["GET"])