API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1# ============================================2# Projet : API-KA3# Fichier : src/utils/backup.py4# Node : m3u96b5# Author : Simon-Pierre Boucher6# Contact : contact@spboucher.ai7# Date : 2026-08-168# ============================================9"""Dump quotidien horodaté par service dans data/backups/YYYY-MM-DD/ (rétention 90 j).1011PostgreSQL : ``pg_dump`` de la table du service, compressé en .sql.gz.12Dev SQLite : export JSON compressé (.json.gz) des lignes du jour.13"""1415from __future__ import annotations1617import argparse18import datetime19import gzip20import json21import shutil22import subprocess23from pathlib import Path2425from sqlalchemy import select2627from src.config import SERVICE_TABLES, SERVICES, get_settings, verify_node28from src.database.db import session_scope29from src.database.models import DATA_MODELS30from src.utils.logger import alert, get_logger3132# Chemin absolu de pg_dump : le cron de 03:30 tourne avec un PATH minimal33# (/usr/bin:/bin) qui ne contient pas /opt/homebrew/bin — un appel nu à34# "pg_dump" y échoue en [Errno 2] FileNotFoundError. On résout via le PATH35# courant quand c'est possible, sinon on retombe sur l'installation Homebrew.36PG_DUMP = shutil.which("pg_dump") or "/opt/homebrew/bin/pg_dump"373839def _day_dir(date_key: datetime.date) -> Path:40 day_dir = get_settings().backups_dir / date_key.isoformat()41 day_dir.mkdir(parents=True, exist_ok=True)42 return day_dir434445def _libpq_url(database_url: str) -> str:46 """Convertit une URL SQLAlchemy en URL libpq pour pg_dump.4748 ``postgresql+psycopg://...`` → ``postgresql://...`` (pg_dump ne connaît49 pas les suffixes de driver SQLAlchemy).50 """51 scheme, _, rest = database_url.partition("://")52 return f"{scheme.split('+', 1)[0]}://{rest}"535455def backup_service(service: str, date_key: datetime.date | None = None) -> Path:56 """Sauvegarde la table d'un service dans data/backups/YYYY-MM-DD/.5758 Args:59 service: Nom du service (``louka``, ``immoka``, …).60 date_key: Date logique du backup (défaut : aujourd'hui).6162 Returns:63 Chemin du fichier de backup compressé créé.64 """65 if service not in SERVICES:66 raise ValueError(f"Service inconnu : {service}")67 settings = get_settings()68 logger = get_logger("apika.backup")69 date_key = date_key or datetime.date.today()70 table = SERVICE_TABLES[service]71 timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%dT%H%M%SZ")72 day_dir = _day_dir(date_key)7374 if settings.database_url.startswith("postgresql"):75 out_path = day_dir / f"{table}_{timestamp}.sql.gz"76 result = subprocess.run(77 [78 PG_DUMP,79 "--dbname",80 _libpq_url(settings.database_url),81 "--table",82 table,83 ],84 capture_output=True,85 check=True,86 )87 with gzip.open(out_path, "wb") as fh:88 fh.write(result.stdout)89 else:90 out_path = day_dir / f"{table}_{timestamp}.json.gz"91 model = DATA_MODELS[service]92 with session_scope() as session:93 rows = (94 session.execute(select(model).where(model.date_key == date_key))95 .scalars()96 .all()97 )98 payload = [99 {100 "id": row.id,101 "payload": row.payload,102 "source": row.source,103 "collected_at": row.collected_at.isoformat(),104 "date_key": row.date_key.isoformat(),105 "checksum": row.checksum,106 }107 for row in rows108 ]109 with gzip.open(out_path, "wt", encoding="utf-8") as fh:110 json.dump(payload, fh, ensure_ascii=False)111112 logger.info(113 "Backup effectué",114 extra={115 "service": service,116 "date_key": date_key.isoformat(),117 "file": str(out_path),118 },119 )120 return out_path121122123def backup_all(date_key: datetime.date | None = None) -> list[Path]:124 """Sauvegarde les 11 services ; un échec n'interrompt pas les autres."""125 paths: list[Path] = []126 for service in SERVICES:127 try:128 paths.append(backup_service(service, date_key))129 except Exception as exc:130 alert(f"Backup échoué pour {service} : {exc}")131 return paths132133134def cleanup_old_backups(retention_days: int | None = None) -> list[Path]:135 """Supprime les répertoires de backup plus vieux que la rétention (min 90 jours)."""136 settings = get_settings()137 retention = max(90, retention_days or settings.backup_retention_days)138 cutoff = datetime.date.today() - datetime.timedelta(days=retention)139 removed: list[Path] = []140 for day_dir in sorted(settings.backups_dir.iterdir()):141 if not day_dir.is_dir():142 continue143 try:144 day = datetime.date.fromisoformat(day_dir.name)145 except ValueError:146 continue147 if day < cutoff:148 shutil.rmtree(day_dir)149 removed.append(day_dir)150 if removed:151 get_logger("apika.backup").info(152 "Backups expirés supprimés",153 extra={"removed": [str(p) for p in removed], "retention_days": retention},154 )155 return removed156157158def main() -> None:159 """Point d'entrée CLI : ``python -m src.utils.backup [--service X] [--cleanup]``."""160 parser = argparse.ArgumentParser(description="Backups API-KA (m3u96b)")161 parser.add_argument("--service", choices=SERVICES, help="Un seul service")162 parser.add_argument(163 "--cleanup", action="store_true", help="Purger les backups expirés"164 )165 args = parser.parse_args()166167 verify_node()168 if args.service:169 backup_service(args.service)170 else:171 backup_all()172 if args.cleanup:173 cleanup_old_backups()174175176if __name__ == "__main__":177 main()178