feat(backup): orchestration backup/list/restore (#89)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user