From 1453990f7a06c8a7b557a0bc0f57ac10c87365b4 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Jun 2026 17:23:57 +0200 Subject: [PATCH] feat(backup): CLI backup/list/restore (#89) Implement CLI interface for the backup module with three subcommands: - backup: create a backup and apply rotation - list: list available backups - restore: restore a specific backup Also add __main__.py to enable 'python -m src.backup'. Co-Authored-By: Claude Sonnet 4.6 --- src/backup/__main__.py | 5 ++++ src/backup/cli.py | 52 ++++++++++++++++++++++++++++++++++++++++ tests/backup/test_cli.py | 35 +++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/backup/__main__.py create mode 100644 src/backup/cli.py create mode 100644 tests/backup/test_cli.py diff --git a/src/backup/__main__.py b/src/backup/__main__.py new file mode 100644 index 0000000..a545ead --- /dev/null +++ b/src/backup/__main__.py @@ -0,0 +1,5 @@ +import sys + +from src.backup.cli import main + +sys.exit(main()) diff --git a/src/backup/cli.py b/src/backup/cli.py new file mode 100644 index 0000000..66f74f7 --- /dev/null +++ b/src/backup/cli.py @@ -0,0 +1,52 @@ +import argparse +import os +import sys +from datetime import datetime, timezone + +from src.backup import service +from src.backup.config import load_config +from src.backup.storage import S3Storage + + +def main(argv=None, env=None, storage=None) -> int: + env = env if env is not None else os.environ + parser = argparse.ArgumentParser(prog="python -m src.backup") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("backup", help="Créer une sauvegarde et appliquer la rotation") + sub.add_parser("list", help="Lister les sauvegardes disponibles") + p_restore = sub.add_parser("restore", help="Restaurer une sauvegarde") + p_restore.add_argument("key", help="Clé S3 de la sauvegarde à restaurer") + args = parser.parse_args(argv) + + config = load_config(env) + if storage is None: + storage = S3Storage.from_config(config) + now = datetime.now(timezone.utc) + + if args.cmd == "backup": + key = service.run_backup(config, storage, now) + print(f"sauvegarde créée : {key}") + return 0 + + if args.cmd == "list": + backups = service.list_backups(config, storage) + if not backups: + print("(aucune sauvegarde)") + return 0 + for key, ts in backups: + print(f"{ts.isoformat()} {key}") + return 0 + + if args.cmd == "restore": + print("⚠ Arrêtez d'abord le service : systemctl stop decpinfo") + backup_copy = service.restore(config, storage, args.key, now) + print(f"restauré depuis : {args.key}") + print(f"copie de secours de l'ancienne base : {backup_copy}") + print("Redémarrez ensuite le service : systemctl start decpinfo") + return 0 + + return 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/tests/backup/test_cli.py b/tests/backup/test_cli.py new file mode 100644 index 0000000..fa4db01 --- /dev/null +++ b/tests/backup/test_cli.py @@ -0,0 +1,35 @@ +import sqlite3 +from pathlib import Path + +from cryptography.fernet import Fernet + +from src.backup import cli +from tests.backup.fakes import FakeStorage + + +def _env(tmp_path: Path) -> dict: + db = tmp_path / "users.sqlite" + conn = sqlite3.connect(str(db)) + conn.execute("CREATE TABLE t (val TEXT)") + conn.execute("INSERT INTO t VALUES ('v1')") + conn.commit() + conn.close() + return { + "USERS_DB_PATH": str(db), + "S3_BUCKET": "b", + "S3_BACKUP_PREFIX": "backups", + "S3_ENDPOINT_URL": "", + "S3_ACCESS_KEY_ID": "AK", + "S3_SECRET_ACCESS_KEY": "SK", + "BACKUP_ENCRYPTION_KEY": Fernet.generate_key().decode(), + } + + +def test_backup_then_list(tmp_path, capsys): + env = _env(tmp_path) + storage = FakeStorage() + assert cli.main(["backup"], env=env, storage=storage) == 0 + assert cli.main(["list"], env=env, storage=storage) == 0 + out = capsys.readouterr().out + assert "users-" in out + assert len(storage.objects) == 1