docs(backup): conception sauvegarde base utilisateurs sur S3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-24 16:51:45 +02:00
parent 3f1c39d5a6
commit 555a87fdf7
@@ -0,0 +1,142 @@
# Sauvegarde de la base utilisateurs sur S3 — conception
Date : 2026-06-24
Statut : conception validée, prête pour le plan d'implémentation
## Contexte et objectif
`users.sqlite` contient les comptes utilisateurs (`src/auth/db.py`) **et** les tokens API
(`src/api/tokens_db.py`) — un seul fichier SQLite, ~36 Ko aujourd'hui, attendu jusqu'à
~500 utilisateurs × quelques centaines de lignes. C'est la seule donnée non reproductible
du projet (la base DECP en Parquet/DuckDB est régénérable, donc hors périmètre).
L'app tourne sur un VPS unique (systemd + gunicorn, `/var/www/<APP_NAME>`). On veut prévenir
toute perte de données par des sauvegardes régulières vers un **stockage compatible S3
(non-Amazon)**, avec rotation multi-paliers et un mécanisme de restauration.
## Décisions validées
- **Déclencheur** : timer systemd sur le VPS (indépendant de gunicorn, robuste aux
redémarrages, pas de duplication entre workers).
- **Chiffrement** : les sauvegardes sont chiffrées côté application _avant_ l'envoi
(données personnelles : emails, hash de mots de passe, tokens).
- **Périmètre** : le seul fichier `users.sqlite` (chemin `USERS_DB_PATH`).
## Vue d'ensemble
Un nouveau module `src/backup/` expose une CLI à trois sous-commandes : `backup`, `list`,
`restore`. Le timer systemd lance `backup` toutes les heures. Chaque exécution :
1. produit un **snapshot cohérent** de `users.sqlite` (API de backup en ligne de SQLite,
sûre même en cas d'écriture concurrente) ;
2. le **compresse** (gzip) puis le **chiffre** ;
3. l'**envoie** sur le S3 sous une clé horodatée ;
4. applique la **rotation** : recalcule l'ensemble à conserver et supprime les obsolètes.
## Schéma de rétention
Union de quatre paliers — une sauvegarde est conservée si elle qualifie pour _au moins un_
palier :
| Palier | Granularité | Horizon | ~ Nb gardés |
| ------------ | --------------------- | ------------------- | ----------- |
| Horaire | 1 par heure | 12 dernières heures | ~12 |
| Bi-quotidien | 1 par tranche de 12 h | 72 dernières heures | ~6 |
| Quotidien | 1 par jour | 21 derniers jours | ~21 |
| Mensuel | 1 par mois calendaire | 12 derniers mois | ~12 |
Soit ~51 fichiers au régime permanent, chacun de quelques dizaines de Ko → stockage
négligeable.
### Algorithme — fonction pure
`select_retained(timestamps: list[datetime], now: datetime) -> set[datetime]` :
- Pour chaque palier à période fixe (horaire, bi-quotidien, quotidien) : regrouper les
horodatages par tranche (`floor((now - t) / période)`), et pour chaque tranche comprise
dans l'horizon, garder le **plus récent** de la tranche.
- Pour le palier mensuel : regrouper par **mois calendaire** (`(année, mois)`) car les mois
ont des durées variables ; garder le plus récent de chaque mois sur les 12 derniers mois.
- Le résultat est l'**union** des ensembles retenus de tous les paliers.
- L'ensemble à supprimer = tous les horodatages présents ensemble retenu.
Aucune I/O dans cette fonction : elle prend la liste des horodatages (extraits des clés S3)
et `now`, et renvoie quoi garder. Testée unitairement de façon exhaustive (chevauchements
de paliers, bascules d'horizon, mois variables, ensemble vide).
## Modules (`src/backup/`)
Chaque module a une responsabilité unique et une interface explicite, testable isolément.
- **`rotation.py`** — `select_retained` (fonction pure, pas d'I/O). Cœur logique, tests
unitaires nombreux.
- **`snapshot.py`** — produit un snapshot SQLite cohérent via l'API de backup en ligne
(`sqlite3.connect(src).backup(dest)`) vers un fichier temporaire, puis gzip. Renvoie le
chemin du fichier compressé.
- **`crypto.py`** — chiffrement/déchiffrement symétrique authentifié (Fernet, lib
`cryptography`). Clé lue depuis `BACKUP_ENCRYPTION_KEY`. Round-trip testé.
- **`storage.py`** — wrapper S3 (`upload`, `list`, `download`, `delete`) via `boto3` avec
`endpoint_url` (compatible tout fournisseur S3 non-Amazon). Configuration lue depuis l'env.
- **`cli.py`** — `argparse` : `backup`, `list`, `restore`. Point d'entrée appelé par le timer
systemd et par l'opérateur pour la restauration.
### Nommage des objets S3
Clé : `<S3_BACKUP_PREFIX>/users-<YYYYMMDDTHHMMSSZ>.sqlite.gz.enc`, horodatage UTC ISO 8601
compact, triable lexicographiquement. La rotation extrait l'horodatage depuis la clé.
## Procédure de sauvegarde (`backup`)
1. Snapshot cohérent de `users.sqlite` → fichier temporaire.
2. gzip.
3. Chiffrement Fernet.
4. Upload sur S3 sous la clé horodatée.
5. Rotation : lister les clés sous le préfixe, parser les horodatages, calculer l'ensemble
retenu via `select_retained`, supprimer le reste.
6. Nettoyage des fichiers temporaires (y compris en cas d'erreur).
Journalisation de chaque étape (succès/échec, clés uploadées/supprimées) pour suivi dans
les logs systemd.
## Procédure de restauration (`restore`, manuelle, avec garde-fous)
- `list` → affiche les sauvegardes disponibles (clé, date, taille), triées.
- `restore <clé>` :
1. télécharge l'objet ;
2. déchiffre puis décompresse vers un fichier temporaire ;
3. **vérifie l'intégrité** SQLite (`PRAGMA integrity_check`) — refuse de restaurer si KO ;
4. fait une **copie de secours** de la base courante (`users.sqlite.bak-<ts>`) ;
5. remplace de façon atomique (`os.replace`).
- Le script avertit d'**arrêter le service** (`systemctl stop <APP_NAME>`) avant la
restauration et de le redémarrer après.
La restauration est volontairement **manuelle** : un outil de reprise sur sinistre ne doit
jamais restaurer automatiquement.
## Déploiement et configuration
- Unités systemd dans `deploy/` :
- `decpinfo-backup.service` (type `oneshot`, exécute `python -m src.backup backup`) ;
- `decpinfo-backup.timer` (`OnCalendar=hourly`, `Persistent=true` pour rattraper un
créneau manqué après un redémarrage).
- Nouvelles variables dans `.template.env` :
- `S3_ENDPOINT_URL`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`,
`S3_BACKUP_PREFIX`, `BACKUP_ENCRYPTION_KEY`.
- Nouvelles dépendances : `boto3`, `cryptography`.
- **Gestion de la clé de chiffrement** : `BACKUP_ENCRYPTION_KEY` doit être sauvegardée
hors du serveur (gestionnaire de secrets / coffre). Si elle est perdue, les sauvegardes
sont irrécupérables.
## Tests
- **Unitaires** : `select_retained` (chevauchements, horizons, mois variables, vide) ;
round-trip `crypto` ; nommage/parsing des clés.
- **Intégration** : snapshot d'une base SQLite réelle → vérifie une base valide ;
cycle complet backup → list → restore sur un faux backend S3 (mock `boto3` ou `moto`),
vérifie l'égalité du contenu restauré et la copie de secours créée.
## Hors périmètre
- Sauvegarde de la base DECP (régénérable).
- Restauration automatique / orchestration multi-serveurs.
- Réplication temps réel.