From cae0a528f3967741dee685bd45d1cb9c6e042620 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Jun 2026 17:18:47 +0200 Subject: [PATCH] feat(backup): orchestration backup/list/restore (#89) Co-Authored-By: Claude Sonnet 4.6 --- src/backup/service.py | 59 ++++++++++++++++++++ tests/backup/test_service.py | 105 +++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/backup/service.py create mode 100644 tests/backup/test_service.py diff --git a/src/backup/service.py b/src/backup/service.py new file mode 100644 index 0000000..c88e3ca --- /dev/null +++ b/src/backup/service.py @@ -0,0 +1,59 @@ +import os +import tempfile +from datetime import datetime +from pathlib import Path + +from src.backup import crypto, naming, snapshot +from src.backup.config import BackupConfig +from src.backup.rotation import select_retained +from src.backup.storage import Storage + + +def _dated_keys(storage: Storage, prefix: str) -> list[tuple[str, datetime]]: + dated: list[tuple[str, datetime]] = [] + for key in storage.list_keys(prefix): + try: + dated.append((key, naming.parse_timestamp(key))) + except ValueError: + continue + return dated + + +def run_backup(config: BackupConfig, storage: Storage, now: datetime) -> str: + data = snapshot.make_snapshot(config.db_path) + encrypted = crypto.encrypt(data, config.encryption_key) + key = naming.make_key(config.prefix, now) + storage.upload_bytes(key, encrypted) + + dated = _dated_keys(storage, config.prefix) + retained = select_retained((ts for _, ts in dated), now) + for k, ts in dated: + if ts not in retained: + storage.delete(k) + return key + + +def list_backups(config: BackupConfig, storage: Storage) -> list[tuple[str, datetime]]: + dated = _dated_keys(storage, config.prefix) + dated.sort(key=lambda item: item[1]) + return dated + + +def restore(config: BackupConfig, storage: Storage, key: str, now: datetime) -> Path: + encrypted = storage.download_bytes(key) + data = crypto.decrypt(encrypted, config.encryption_key) + + with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as tmp: + tmp_path = Path(tmp.name) + snapshot.write_snapshot(data, tmp_path) + if not snapshot.verify_integrity(tmp_path): + tmp_path.unlink(missing_ok=True) + raise ValueError(f"Sauvegarde corrompue, restauration annulée : {key}") + + db_path = config.db_path + stamp = now.strftime("%Y%m%dT%H%M%SZ") + backup_copy = db_path.with_name(f"{db_path.name}.bak-{stamp}") + if db_path.exists(): + backup_copy.write_bytes(db_path.read_bytes()) + os.replace(tmp_path, db_path) + return backup_copy diff --git a/tests/backup/test_service.py b/tests/backup/test_service.py new file mode 100644 index 0000000..8a17f14 --- /dev/null +++ b/tests/backup/test_service.py @@ -0,0 +1,105 @@ +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from cryptography.fernet import Fernet + +from src.backup import service +from src.backup.config import BackupConfig +from src.backup.naming import make_key +from tests.backup.fakes import FakeStorage + +NOW = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc) + + +def _make_db(path: Path, val: str): + conn = sqlite3.connect(str(path)) + conn.execute("CREATE TABLE t (val TEXT)") + conn.execute("INSERT INTO t VALUES (?)", (val,)) + conn.commit() + conn.close() + + +def _config(db_path: Path) -> BackupConfig: + return BackupConfig( + db_path=db_path, + bucket="b", + prefix="backups", + endpoint_url=None, + access_key="AK", + secret_key="SK", + encryption_key=Fernet.generate_key().decode(), + ) + + +def test_run_backup_uploads_key(tmp_path): + db = tmp_path / "users.sqlite" + _make_db(db, "v1") + storage = FakeStorage() + key = service.run_backup(_config(db), storage, NOW) + assert key in storage.objects + assert key == "backups/users-20260624T120000Z.sqlite.gz.enc" + + +def test_run_backup_applies_rotation(tmp_path): + db = tmp_path / "users.sqlite" + _make_db(db, "v1") + cfg = _config(db) + storage = FakeStorage() + # une vieille sauvegarde de >12 mois doit être supprimée par la rotation + stale = make_key(cfg.prefix, NOW - timedelta(days=400)) + storage.objects[stale] = b"old" + service.run_backup(cfg, storage, NOW) + assert stale not in storage.objects + + +def test_list_backups_sorted(tmp_path): + db = tmp_path / "users.sqlite" + _make_db(db, "v1") + cfg = _config(db) + storage = FakeStorage() + storage.objects[make_key(cfg.prefix, NOW)] = b"x" + storage.objects[make_key(cfg.prefix, NOW - timedelta(hours=1))] = b"x" + storage.objects["backups/garbage.txt"] = b"x" # ignoré + result = service.list_backups(cfg, storage) + dates = [ts for _, ts in result] + assert dates == sorted(dates) + assert len(result) == 2 + + +def test_restore_replaces_db_and_keeps_safety_copy(tmp_path): + db = tmp_path / "users.sqlite" + _make_db(db, "original") + cfg = _config(db) + storage = FakeStorage() + key = service.run_backup(cfg, storage, NOW) # sauvegarde "original" + + # la base change ensuite + db.unlink() + _make_db(db, "modifie") + + backup_copy = service.restore(cfg, storage, key, NOW) + + conn = sqlite3.connect(str(db)) + val = conn.execute("SELECT val FROM t").fetchone()[0] + conn.close() + assert val == "original" + assert backup_copy.exists() # copie de secours de la base "modifie" + + +def test_restore_rejects_corrupt_backup(tmp_path): + db = tmp_path / "users.sqlite" + _make_db(db, "original") + cfg = _config(db) + storage = FakeStorage() + from src.backup import crypto + + bad_key = make_key(cfg.prefix, NOW) + import gzip + + storage.objects[bad_key] = crypto.encrypt( + gzip.compress(b"not sqlite"), cfg.encryption_key + ) + with pytest.raises(ValueError): + service.restore(cfg, storage, bad_key, NOW)