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
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:
+100
-21
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user