# ============================================ # Projet : API-KA # Fichier : src/utils/backup.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-16 # ============================================ """Dump quotidien horodaté par service dans data/backups/YYYY-MM-DD/ (rétention 90 j). PostgreSQL : ``pg_dump`` de la table du service, compressé en .sql.gz. Dev SQLite : export JSON compressé (.json.gz) des lignes du jour. """ from __future__ import annotations import argparse import datetime import gzip import json import shutil import subprocess from pathlib import Path from sqlalchemy import select from src.config import SERVICE_TABLES, SERVICES, get_settings, verify_node from src.database.db import session_scope from src.database.models import DATA_MODELS from src.utils.logger import alert, get_logger # Chemin absolu de pg_dump : le cron de 03:30 tourne avec un PATH minimal # (/usr/bin:/bin) qui ne contient pas /opt/homebrew/bin — un appel nu à # "pg_dump" y échoue en [Errno 2] FileNotFoundError. On résout via le PATH # courant quand c'est possible, sinon on retombe sur l'installation Homebrew. PG_DUMP = shutil.which("pg_dump") or "/opt/homebrew/bin/pg_dump" def _day_dir(date_key: datetime.date) -> Path: day_dir = get_settings().backups_dir / date_key.isoformat() day_dir.mkdir(parents=True, exist_ok=True) return day_dir def _libpq_url(database_url: str) -> str: """Convertit une URL SQLAlchemy en URL libpq pour pg_dump. ``postgresql+psycopg://...`` → ``postgresql://...`` (pg_dump ne connaît pas les suffixes de driver SQLAlchemy). """ scheme, _, rest = database_url.partition("://") return f"{scheme.split('+', 1)[0]}://{rest}" def backup_service(service: str, date_key: datetime.date | None = None) -> Path: """Sauvegarde la table d'un service dans data/backups/YYYY-MM-DD/. Args: service: Nom du service (``louka``, ``immoka``, …). date_key: Date logique du backup (défaut : aujourd'hui). Returns: Chemin du fichier de backup compressé créé. """ if service not in SERVICES: raise ValueError(f"Service inconnu : {service}") settings = get_settings() logger = get_logger("apika.backup") date_key = date_key or datetime.date.today() table = SERVICE_TABLES[service] timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%dT%H%M%SZ") day_dir = _day_dir(date_key) if settings.database_url.startswith("postgresql"): out_path = day_dir / f"{table}_{timestamp}.sql.gz" result = subprocess.run( [ PG_DUMP, "--dbname", _libpq_url(settings.database_url), "--table", table, ], capture_output=True, check=True, ) with gzip.open(out_path, "wb") as fh: fh.write(result.stdout) else: out_path = day_dir / f"{table}_{timestamp}.json.gz" model = DATA_MODELS[service] with session_scope() as session: rows = ( session.execute(select(model).where(model.date_key == date_key)) .scalars() .all() ) payload = [ { "id": row.id, "payload": row.payload, "source": row.source, "collected_at": row.collected_at.isoformat(), "date_key": row.date_key.isoformat(), "checksum": row.checksum, } for row in rows ] with gzip.open(out_path, "wt", encoding="utf-8") as fh: json.dump(payload, fh, ensure_ascii=False) logger.info( "Backup effectué", extra={ "service": service, "date_key": date_key.isoformat(), "file": str(out_path), }, ) return out_path def backup_all(date_key: datetime.date | None = None) -> list[Path]: """Sauvegarde les 11 services ; un échec n'interrompt pas les autres.""" paths: list[Path] = [] for service in SERVICES: try: paths.append(backup_service(service, date_key)) except Exception as exc: alert(f"Backup échoué pour {service} : {exc}") return paths def cleanup_old_backups(retention_days: int | None = None) -> list[Path]: """Supprime les répertoires de backup plus vieux que la rétention (min 90 jours).""" settings = get_settings() retention = max(90, retention_days or settings.backup_retention_days) cutoff = datetime.date.today() - datetime.timedelta(days=retention) removed: list[Path] = [] for day_dir in sorted(settings.backups_dir.iterdir()): if not day_dir.is_dir(): continue try: day = datetime.date.fromisoformat(day_dir.name) except ValueError: continue if day < cutoff: shutil.rmtree(day_dir) removed.append(day_dir) if removed: get_logger("apika.backup").info( "Backups expirés supprimés", extra={"removed": [str(p) for p in removed], "retention_days": retention}, ) return removed def main() -> None: """Point d'entrée CLI : ``python -m src.utils.backup [--service X] [--cleanup]``.""" parser = argparse.ArgumentParser(description="Backups API-KA (m3u96b)") parser.add_argument("--service", choices=SERVICES, help="Un seul service") parser.add_argument( "--cleanup", action="store_true", help="Purger les backups expirés" ) args = parser.parse_args() verify_node() if args.service: backup_service(args.service) else: backup_all() if args.cleanup: cleanup_old_backups() if __name__ == "__main__": main()