fix(ops): la sauvegarde des contrats archivait le mauvais repertoire

OBS-006. Trois racines etaient baties sur os.getcwd() : le magasin de
documents, les journaux et les sauvegardes. La vague G a corrige la
premiere, parce qu'elle bloquait aussi OPS-011, et a laisse les deux autres.
C'est la lecon deja consignee deux fois : un motif fautif corrige dans une
seule couche reste dans les autres.

Le plus serieux n'est pas le motif, c'est l'ecart qu'il a ouvert.
backup.py gardait sa propre constante DOCUMENTS_DIR sur os.getcwd(), donc
il ignorait DOCUMENTS_ROOT -- la variable que la vague G a introduite et que
docs/deployment.md dit maintenant de regler pour sortir les televersements
des repertoires de version. Des qu'un exploitant suit cette consigne, le
script archive un repertoire ou l'application n'a jamais rien ecrit. Et
comme il repond a un repertoire absent par une ligne d'information et un
code de sortie 0, une tache planifiee qui surveille le code de sortie voit
vert indefiniment.

Autrement dit : plus l'exploitant suivait correctement la documentation de
deploiement, plus surement ses sauvegardes de contrats etaient vides.

Les trois racines viennent desormais d'app/storage.py, resolues a l'appel et
non a l'import, et la sauvegarde imprime la source qu'elle a utilisee. Le
message d'absence nomme le chemin ou elle a cherche : "No documents
directory found" se lisait comme "il n'y a pas de documents" plutot que
comme "je regarde au mauvais endroit".

Le test qui porte est celui qui ouvre l'archive : un zip vide est un fichier
de taille non nulle, donc verifier qu'un fichier a ete produit ne prouvait
rien. Verifie par mutation.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-11 18:34:52 -04:00
co-authored by Claude Opus 5
parent 66f2838402
commit bda23dbb67
6 changed files with 287 additions and 11 deletions
+39 -5
View File
@@ -37,13 +37,33 @@ import sys
from datetime import datetime, timedelta
from urllib.parse import unquote, urlparse
# Run as `python app/supporting_scripts/backup.py`, sys.path[0] is this
# script's directory, so the application package is not importable. It has
# to be — see DOCUMENTS_DIR below.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app.storage import backups_root, documents_root # noqa: E402 — needs the path above
# Configuration
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
BACKUP_DIR = backups_root()
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
PG_DUMP = os.getenv('PG_DUMP', 'pg_dump')
PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
# There is deliberately no DOCUMENTS_DIR constant any more. It held
# `os.path.join(os.getcwd(), 'documents')`, which had stopped being true:
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
# keep uploads outside the releases, and docs/deployment.md now tells the
# operator to set it — at which point this script archived a directory the
# application had never written to. It does not fail on a missing directory
# either; it prints "No documents directory found", skips, and exits 0.
#
# So the more correctly an operator followed the deployment documentation,
# the more certainly their contract backups were empty (OBS-006).
#
# backup_documents() now asks app.storage, at call time, the same question
# the upload path asks. One source of truth, and one that a test can move.
class BackupError(Exception):
"""Raised when a backup step fails in a way that must stop the run."""
@@ -235,20 +255,31 @@ def verify_backup(backup_path):
def backup_documents():
"""Archive the uploaded contract documents directory.
The directory is resolved through `app.storage.documents_root()` — the
same function the upload path uses — so that setting DOCUMENTS_ROOT
moves both together. Resolved here rather than at import, so that what
is backed up depends on the environment the run has, not on the one the
module happened to be imported with.
Returns:
str: Path to the created archive, or None if there is nothing to
archive. Signed contracts live only on disk, so losing this
directory loses the documents themselves.
"""
if not os.path.exists(DOCUMENTS_DIR):
print('[INFO] No documents directory found. Skipping document backup.')
documents_dir = documents_root()
if not os.path.exists(documents_dir):
# Says where it looked. The previous message named no path, so an
# operator who had moved the documents read it as "there are no
# documents" rather than "I am looking in the wrong place".
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
try:
shutil.make_archive(archive_basename, 'zip', DOCUMENTS_DIR)
shutil.make_archive(archive_basename, 'zip', documents_dir)
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
# This runs after the database dump has already succeeded. Letting
# anything through here would abort the script with a traceback and
@@ -314,6 +345,9 @@ def main(argv=None):
print('=== Team Tryouts Backup ===')
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print(f'Backup directory: {BACKUP_DIR}')
# Printed because it is the value that was wrong for months without
# anyone being able to see it from the output.
print(f'Document source: {documents_root()}')
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
print()