SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
5.8 KB · 178 lines python
Raw Blame History
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