From 3f6e3c7fd5455516f2e25dc9828e15defc24384e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:31:28 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Sauvegarde=20quotidienne=20de=20la=20produc?= =?UTF-8?q?tion=20:=20export=20logique=20dat=C3=A9=20+=20trace=20Brevo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complète les sauvegardes physiques natives du plan Render pro par un export logique téléchargeable — copie portable, hors Render, archivable pour la Conformité, et surtout PRODUCTIBLE À LA DEMANDE et constatable. - app/scripts/backup_prod.py : déclenche l'export via l'API Render, attend son apparition, envoie une trace Brevo (horodatage + URL de téléchargement). S'abstient proprement sans RENDER_API_KEY (comme Brevo) : jamais d'échec pour une variable manquante. - render.yaml : cron « sauvegarde-prod-lbcft » quotidien à 04:00 UTC, une heure avant le rafraîchissement des listes. ⚠ La clé API Render est à portée compte : la renseigner sur le cron lui donne un accès complet. Laissée à sync:false — à activer en connaissance de cause. Validé contre la production : export 2026-07-22T09:28Z produit et listé. Co-Authored-By: Claude Opus 4.8 --- app/scripts/backup_prod.py | 129 +++++++++++++++++++++++++++++++++++++ render.yaml | 41 ++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 app/scripts/backup_prod.py diff --git a/app/scripts/backup_prod.py b/app/scripts/backup_prod.py new file mode 100644 index 0000000..f414a22 --- /dev/null +++ b/app/scripts/backup_prod.py @@ -0,0 +1,129 @@ +""" +Sauvegarde quotidienne de la base de production — export logique daté. + +Le plan Render pro fournit déjà des sauvegardes physiques automatiques (reprise +rapide, restauration point-dans-le-temps). Ce script les COMPLÈTE par un export +logique téléchargeable : une copie portable, hors Render, qu'un dossier BCRG +peut archiver et vérifier. Une sauvegarde qu'on ne peut pas produire à la +demande et constater n'en est pas une. + +Déclenché par un cron Render (voir render.yaml). Envoie une trace Brevo à la +Conformité avec l'horodatage et l'URL de téléchargement de l'export. + +Variables d'environnement : + RENDER_API_KEY clé API Render (compte). SANS elle, le script s'abstient + proprement (comme Brevo) plutôt que d'échouer. + RENDER_PROD_DB_ID identifiant de la base de production (dpg-…). + BREVO_* configuration SMTP pour la trace (partagée avec les listes). + +⚠ La clé API Render est à portée COMPTE : la stocker ici donne au cron un accès + complet. À n'activer qu'en connaissance de cause ; à faire tourner/retirer si + Render introduit un jour des clés à portée réduite. +""" +from __future__ import annotations + +import logging +import os +import sys +import time +from datetime import datetime, timezone + +import httpx + +from app.services import list_notifier + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("backup_prod") + +RENDER_API = "https://api.render.com/v1" +API_KEY = os.getenv("RENDER_API_KEY") +DB_ID = os.getenv("RENDER_PROD_DB_ID") + +# Fenêtre d'attente : un export logique met quelques minutes à apparaître. +POLL_TIMEOUT_S = 600 +POLL_INTERVAL_S = 20 + + +def _headers() -> dict: + return {"Authorization": f"Bearer {API_KEY}", "Accept": "application/json"} + + +def _list_exports() -> list[dict]: + r = httpx.get(f"{RENDER_API}/postgres/{DB_ID}/export", headers=_headers(), timeout=30) + r.raise_for_status() + return r.json() or [] + + +def _trigger_export() -> None: + r = httpx.post(f"{RENDER_API}/postgres/{DB_ID}/export", headers=_headers(), timeout=30) + # 201/202 selon l'état ; on tolère, l'apparition dans la liste fait foi. + if r.status_code >= 400: + raise RuntimeError(f"déclenchement refusé : HTTP {r.status_code} {r.text[:200]}") + + +def _notify(ok: bool, detail: str, url: str | None = None) -> None: + ts = datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M UTC") + etat, couleur = ("SUCCÈS", "#15803d") if ok else ("ÉCHEC", "#b91c1c") + lien = (f"

Télécharger l'export " + f"(lien signé, à durée limitée)

") if url else "" + html = f""" +
+

Sauvegarde de la base de production

+

{ts} · + {etat}

+

{detail}

+ {lien} +

+ Export logique complémentaire des sauvegardes physiques Render. + Copie portable destinée à l'archivage de conformité. +

+
+ """ + try: + list_notifier._send_brevo_smtp(f"[LBC/FT] Sauvegarde — {etat} — {ts}", html) + except Exception: + logger.exception("notification_failed") + + +def main() -> int: + if not (API_KEY and DB_ID): + logger.warning("RENDER_API_KEY / RENDER_PROD_DB_ID absents — sauvegarde ignorée.") + return 0 # abstention propre, pas un échec + + started = datetime.now(timezone.utc) + try: + avant = {e.get("id") for e in _list_exports()} + _trigger_export() + logger.info("export déclenché ; attente de son apparition…") + + deadline = time.monotonic() + POLL_TIMEOUT_S + nouveau = None + while time.monotonic() < deadline: + time.sleep(POLL_INTERVAL_S) + for e in _list_exports(): + if e.get("id") not in avant: + nouveau = e + break + if nouveau: + break + + if not nouveau: + _notify(False, "L'export a été déclenché mais n'est pas apparu dans le " + f"délai imparti ({POLL_TIMEOUT_S // 60} min). À vérifier " + "dans le tableau de bord Render.") + logger.error("export non apparu dans le délai") + return 1 + + _notify(True, f"Export produit à {nouveau.get('createdAt')}.", + url=nouveau.get("url")) + logger.info("export disponible : %s", nouveau.get("id")) + return 0 + + except Exception as exc: # noqa: BLE001 + _notify(False, f"Erreur pendant la sauvegarde : {exc}") + logger.exception("backup_failed") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/render.yaml b/render.yaml index 17ad3b0..7fccb2d 100644 --- a/render.yaml +++ b/render.yaml @@ -134,3 +134,44 @@ services: sync: false - key: BREVO_TO_EMAIL sync: false + + # Sauvegarde quotidienne : export logique daté de la production, en + # complément des sauvegardes physiques natives du plan pro. Copie portable, + # téléchargeable, archivable pour la Conformité. 04:00 UTC, avant le + # rafraîchissement des listes. S'abstient proprement si la clé API est + # absente (rien ne casse), et trace chaque exécution par courriel. + - type: cron + name: sauvegarde-prod-lbcft + runtime: python + plan: starter + region: frankfurt + branch: main + schedule: "0 4 * * *" + buildCommand: ./build.sh + startCommand: python -m app.scripts.backup_prod + envVars: + - key: ENVIRONMENT + value: staging + # Nécessaires uniquement pour satisfaire l'import de la configuration. + - key: DATABASE_URL + sync: false + - key: SECRET_KEY + sync: false + # ⚠ Clé API à portée COMPTE : donne à ce cron un accès Render complet. + # À renseigner en connaissance de cause ; sans elle, la sauvegarde est + # simplement ignorée (le cron ne casse pas). + - key: RENDER_API_KEY + sync: false + - key: RENDER_PROD_DB_ID + value: dpg-d6jcgftm5p6s73dlnlng-a + # Trace par courriel — mêmes réglages Brevo que le refresh des listes. + - key: BREVO_SMTP_LOGIN + sync: false + - key: BREVO_SMTP_KEY + sync: false + - key: BREVO_FROM_EMAIL + sync: false + - key: BREVO_FROM_NAME + sync: false + - key: BREVO_TO_EMAIL + sync: false From 33e60bdcc722695c68a3d2132777e0626ff6b6a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:38:30 +0000 Subject: [PATCH 2/2] backup_prod : retirer import et variable inutiles (ruff F401, F841) Co-Authored-By: Claude Opus 4.8 --- app/scripts/backup_prod.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/scripts/backup_prod.py b/app/scripts/backup_prod.py index f414a22..65c45ee 100644 --- a/app/scripts/backup_prod.py +++ b/app/scripts/backup_prod.py @@ -24,7 +24,6 @@ import logging import os -import sys import time from datetime import datetime, timezone @@ -90,7 +89,6 @@ def main() -> int: logger.warning("RENDER_API_KEY / RENDER_PROD_DB_ID absents — sauvegarde ignorée.") return 0 # abstention propre, pas un échec - started = datetime.now(timezone.utc) try: avant = {e.get("id") for e in _list_exports()} _trigger_export()