feat(6847): a rollback first backs up the state it replaces
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Successful in 24s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Successful in 24s
backup_job.rollback_to_backup: 1. Checks the backup can apply here (backup.check_replace, which shares _replace_mode with replace_from_backup), refusing before anything is written. 2. Takes the backup lock, so no scheduled backup runs during the rollback. 3. Saves the current store with save_state_before_restore. 4. Replaces the store. The safety file goes to the backup destination when backups are on and it takes it. Otherwise it goes to DATA_DIR/pre-restore, with a note when the destination refused. If neither takes it the rollback does not run. The result names where the file went. Milestone 582, step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -221,3 +221,124 @@ def test_routes_and_scheduler_are_wired():
|
||||
assert callable(backup_job.start_backup_scheduler)
|
||||
assert callable(backup_job.stop_backup_scheduler)
|
||||
assert backup_destination.SECRET_SETTING == "backup_s3_secret"
|
||||
|
||||
|
||||
# ── The backup a rollback takes of what it replaces ────────────────────────
|
||||
|
||||
|
||||
def _safety_patches(tmp_path, *, config, deliver=None):
|
||||
from grimoire.config import Config
|
||||
|
||||
async def load_config():
|
||||
return config
|
||||
|
||||
async def load_secret():
|
||||
return (None, False)
|
||||
|
||||
async def export():
|
||||
return {"version": 28, "scope": "full", "users": []}
|
||||
|
||||
async def delivered(payload, config, secret, at, **kw):
|
||||
return Delivered("grimoire-20261015T020000Z.tar", 1234, "/backups")
|
||||
|
||||
return [
|
||||
patch.object(backup_job, "load_config", load_config),
|
||||
patch.object(backup_job, "load_secret", load_secret),
|
||||
patch.object(backup_job, "deliver", deliver or delivered),
|
||||
patch("grimoire.services.backup.export_full_backup", export),
|
||||
patch.object(Config, "DATA_DIR", str(tmp_path)),
|
||||
]
|
||||
|
||||
|
||||
def test_the_state_before_a_rollback_goes_to_the_backup_destination(tmp_path):
|
||||
saved = _run(backup_job.save_state_before_restore(),
|
||||
_safety_patches(tmp_path, config=BackupConfig(enabled=True)))
|
||||
assert saved == {"file": "grimoire-20261015T020000Z.tar", "size_bytes": 1234,
|
||||
"where": "/backups"}
|
||||
assert not (tmp_path / backup_job.PRE_RESTORE_DIR).exists()
|
||||
|
||||
|
||||
def test_with_backups_off_it_is_kept_on_the_instance(tmp_path):
|
||||
saved = _run(backup_job.save_state_before_restore(),
|
||||
_safety_patches(tmp_path, config=BackupConfig(enabled=False)))
|
||||
folder = tmp_path / backup_job.PRE_RESTORE_DIR
|
||||
assert saved["where"] == str(folder)
|
||||
assert (folder / saved["file"]).stat().st_size == saved["size_bytes"] > 0
|
||||
assert "note" not in saved
|
||||
|
||||
|
||||
def test_a_destination_that_refuses_it_falls_back_to_the_instance_and_says_so(tmp_path):
|
||||
async def refuse(*a, **kw):
|
||||
raise DestinationError("/backups isn't there.")
|
||||
|
||||
saved = _run(backup_job.save_state_before_restore(),
|
||||
_safety_patches(tmp_path, config=BackupConfig(enabled=True), deliver=refuse))
|
||||
assert saved["where"] == str(tmp_path / backup_job.PRE_RESTORE_DIR)
|
||||
assert "/backups isn't there." in saved["note"]
|
||||
|
||||
|
||||
def test_a_rollback_that_cannot_save_the_current_state_does_not_run(tmp_path):
|
||||
from grimoire.services import backup
|
||||
|
||||
calls = []
|
||||
|
||||
async def check(data):
|
||||
return "rollback"
|
||||
|
||||
async def replace(data):
|
||||
calls.append("replace")
|
||||
return {"mode": "rollback"}
|
||||
|
||||
async def fail():
|
||||
raise OSError("disk full")
|
||||
|
||||
patches = [
|
||||
patch.object(backup, "check_replace", check),
|
||||
patch.object(backup, "replace_from_backup", replace),
|
||||
patch.object(backup_job, "save_state_before_restore", fail),
|
||||
]
|
||||
with pytest.raises(OSError):
|
||||
_run(backup_job.rollback_to_backup({}), patches)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_a_refused_rollback_writes_no_safety_backup():
|
||||
from grimoire.services import backup
|
||||
|
||||
calls = []
|
||||
|
||||
async def check(data):
|
||||
raise backup.RestoreRefused("from another instance")
|
||||
|
||||
async def save():
|
||||
calls.append("save")
|
||||
|
||||
patches = [
|
||||
patch.object(backup, "check_replace", check),
|
||||
patch.object(backup_job, "save_state_before_restore", save),
|
||||
]
|
||||
with pytest.raises(backup.RestoreRefused):
|
||||
_run(backup_job.rollback_to_backup({}), patches)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_a_rollback_reports_where_the_state_it_replaced_went():
|
||||
from grimoire.services import backup
|
||||
|
||||
async def check(data):
|
||||
return "rollback"
|
||||
|
||||
async def replace(data):
|
||||
return {"mode": "rollback", "restored": {"notes": 3}}
|
||||
|
||||
async def save():
|
||||
return {"file": "grimoire-x.tar", "size_bytes": 9, "where": "/data/pre-restore"}
|
||||
|
||||
patches = [
|
||||
patch.object(backup, "check_replace", check),
|
||||
patch.object(backup, "replace_from_backup", replace),
|
||||
patch.object(backup_job, "save_state_before_restore", save),
|
||||
]
|
||||
result = _run(backup_job.rollback_to_backup({}), patches)
|
||||
assert result["safety_backup"]["where"] == "/data/pre-restore"
|
||||
assert result["restored"] == {"notes": 3}
|
||||
|
||||
Reference in New Issue
Block a user