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>
345 lines
12 KiB
Python
345 lines
12 KiB
Python
"""The scheduled backup job (services/backup_job.py) and the admin surface
|
|
over it. No database: admin settings are an in-memory dict, and the export
|
|
and delivery are stand-ins, so what is under test is the job's own contract —
|
|
it records every outcome, keeps a bounded history, runs only at its hour,
|
|
and never runs twice at once.
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from grimoire.services import backup_destination, backup_job
|
|
from grimoire.services.backup_destination import (
|
|
BackupConfig, Delivered, DestinationError, apply_changes,
|
|
)
|
|
|
|
NOW = datetime(2026, 10, 15, 2, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
class _Settings(dict):
|
|
async def get(self, key, default=""):
|
|
return self.get_value(key, default)
|
|
|
|
def get_value(self, key, default):
|
|
return dict.get(self, key, default)
|
|
|
|
async def set(self, key, value):
|
|
self[key] = value
|
|
|
|
|
|
def _patched(settings, deliver=None, config=BackupConfig(enabled=True)):
|
|
async def load_config():
|
|
return config
|
|
|
|
async def load_secret():
|
|
return (None, False)
|
|
|
|
async def export():
|
|
return {"version": 25, "users": []}
|
|
|
|
async def delivered(payload, config, secret, at, **kw):
|
|
return Delivered("grimoire-20261015T020000Z.tar", 1234, "/backups")
|
|
|
|
return [
|
|
patch.object(backup_job, "get_admin_setting", settings.get),
|
|
patch.object(backup_job, "set_admin_setting", settings.set),
|
|
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),
|
|
]
|
|
|
|
|
|
def _run(coro, patches):
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
return asyncio.run(coro)
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
|
|
|
|
def test_a_good_run_is_kept_with_its_file_and_size():
|
|
settings = _Settings()
|
|
run = _run(backup_job.run_backup("manual"), _patched(settings))
|
|
assert run["status"] == "ok"
|
|
assert (run["file"], run["size_bytes"], run["trigger"]) == (
|
|
"grimoire-20261015T020000Z.tar", 1234, "manual",
|
|
)
|
|
runs = _run(backup_job.list_runs(), _patched(settings))
|
|
assert runs == [run]
|
|
|
|
|
|
def test_a_failed_run_is_kept_with_its_reason_in_words():
|
|
async def refuse(*a, **kw):
|
|
raise DestinationError("/backups isn't there.")
|
|
|
|
settings = _Settings()
|
|
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=refuse))
|
|
assert run["status"] == "failed"
|
|
assert run["error"] == "/backups isn't there."
|
|
|
|
|
|
def test_an_unexpected_error_is_a_failed_run_not_a_crash():
|
|
async def boom(*a, **kw):
|
|
raise RuntimeError("disk on fire")
|
|
|
|
settings = _Settings()
|
|
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=boom))
|
|
assert run["status"] == "failed"
|
|
assert "disk on fire" in run["error"]
|
|
|
|
|
|
def test_a_run_that_hangs_is_abandoned_at_the_deadline():
|
|
async def hang(*a, **kw):
|
|
await asyncio.sleep(10)
|
|
|
|
settings = _Settings()
|
|
with patch.object(backup_job, "RUN_DEADLINE", timedelta(seconds=0.05)):
|
|
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=hang))
|
|
assert run["status"] == "failed"
|
|
assert "didn't finish" in run["error"]
|
|
assert not backup_job.is_running()
|
|
|
|
|
|
def test_the_history_keeps_only_the_newest_runs():
|
|
settings = _Settings()
|
|
|
|
async def many():
|
|
for _ in range(backup_job.KEEP_RUNS + 5):
|
|
await backup_job.run_backup("scheduled")
|
|
return await backup_job.list_runs()
|
|
|
|
runs = _run(many(), _patched(settings))
|
|
assert len(runs) == backup_job.KEEP_RUNS
|
|
assert runs[0]["started_at"] >= runs[-1]["started_at"]
|
|
|
|
|
|
def test_a_second_run_while_one_is_going_is_refused():
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def slow(*a, **kw):
|
|
started.set()
|
|
await release.wait()
|
|
return Delivered("x.tar", 1, "/backups")
|
|
|
|
async def both():
|
|
first = asyncio.create_task(backup_job.run_backup("manual"))
|
|
await started.wait()
|
|
with pytest.raises(backup_job.BackupBusy):
|
|
await backup_job.run_backup("manual")
|
|
release.set()
|
|
return await first
|
|
|
|
run = _run(both(), _patched(_Settings(), deliver=slow))
|
|
assert run["status"] == "ok"
|
|
|
|
|
|
@pytest.mark.parametrize("enabled,hour,ran", [
|
|
(True, NOW.hour, True),
|
|
(True, (NOW.hour + 1) % 24, False),
|
|
(False, NOW.hour, False),
|
|
])
|
|
def test_the_hourly_tick_runs_only_when_on_and_at_its_hour(enabled, hour, ran):
|
|
calls = []
|
|
|
|
async def fake_run(trigger):
|
|
calls.append(trigger)
|
|
|
|
class _Now(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return NOW
|
|
|
|
async def config():
|
|
return BackupConfig(enabled=enabled, hour=hour)
|
|
|
|
with patch.object(backup_job, "load_config", config), \
|
|
patch.object(backup_job, "run_backup", fake_run), \
|
|
patch.object(backup_job, "datetime", _Now):
|
|
asyncio.run(backup_job._hourly_check())
|
|
assert calls == (["scheduled"] if ran else [])
|
|
|
|
|
|
# ── The page's verdict ───────────────────────────────────────────────────────
|
|
|
|
def _ok(at):
|
|
return {"status": "ok", "finished_at": at.isoformat()}
|
|
|
|
|
|
def test_a_recent_success_is_not_overdue():
|
|
s = backup_job.summarise([_ok(NOW - timedelta(hours=3))], enabled=True, now=NOW)
|
|
assert s["overdue"] is False
|
|
assert s["last_success"]["status"] == "ok"
|
|
|
|
|
|
def test_failures_after_an_old_success_are_overdue():
|
|
runs = [{"status": "failed", "finished_at": NOW.isoformat()},
|
|
_ok(NOW - timedelta(hours=30))]
|
|
s = backup_job.summarise(runs, enabled=True, now=NOW)
|
|
assert s["overdue"] is True
|
|
assert s["last_run"]["status"] == "failed"
|
|
|
|
|
|
def test_off_is_never_overdue_and_nothing_run_yet_is_not_late():
|
|
assert backup_job.summarise([], enabled=False, now=NOW)["overdue"] is False
|
|
assert backup_job.summarise([], enabled=True, now=NOW)["overdue"] is False
|
|
failed_only = [{"status": "failed", "finished_at": NOW.isoformat()}]
|
|
assert backup_job.summarise(failed_only, enabled=True, now=NOW)["overdue"] is True
|
|
|
|
|
|
# ── What an admin sends ──────────────────────────────────────────────────────
|
|
|
|
def test_changes_apply_on_top_of_the_current_config():
|
|
config = apply_changes(BackupConfig(), {"enabled": True, "hour": "5", "s3_secret": "x"})
|
|
assert (config.enabled, config.hour, config.destination) == (True, 5, "folder")
|
|
|
|
|
|
@pytest.mark.parametrize("data,said", [
|
|
({"hour": 24}, "from 0 to 23"),
|
|
({"hour": "two"}, "whole number"),
|
|
({"destination": "ftp"}, "folder or an S3 bucket"),
|
|
({"enabled": "yes"}, "true or false"),
|
|
])
|
|
def test_a_typed_mistake_is_refused_with_the_reason(data, said):
|
|
with pytest.raises(ValueError, match=said):
|
|
apply_changes(BackupConfig(), data)
|
|
|
|
|
|
def test_routes_and_scheduler_are_wired():
|
|
from grimoire.app import create_app
|
|
from grimoire.routes import admin as admin_routes
|
|
|
|
for name in ("get_backups", "update_backups", "check_backups_destination", "run_backup_now"):
|
|
assert callable(getattr(admin_routes, name))
|
|
rules = {r.rule for r in create_app().url_map.iter_rules()}
|
|
assert {"/api/admin/backups", "/api/admin/backups/check", "/api/admin/backups/run"} <= rules
|
|
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}
|