ADR 0019 – Sauvegardes planifiées : service compose dédié, instantanés horodatés (dump, fichiers, `config/`), restauration sur base vide testée
Accepté (2026-10-15, mode autonome)
ADR 0019 – Sauvegardes planifiées : service compose dédié, instantanés horodatés (dump, fichiers, config/), restauration sur base vide testée
Statut : Accepté (2026-10-15, mode autonome)
Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles).
-
Où : un service compose dédié
backup, construit depuisbackup/Dockerfilesurpostgres:17-alpine(même version majeure que le serveur), avecrsyncetjq. Il contient des scripts POSIXshet une boucle de planification, sans cron. L’application Spring n’est pas modifiée : pas de@Scheduled, pas de migration, pas d’endpoint, pas d’écran, pas de changement du format YAML ni des méta-schémas. -
Quoi : un instantané par exécution, dans un dossier horodaté
/backups/<AAAAMMJJTHHMMSSZ>/qui contient :db.dump(pg_dump -Fc) ;files/(copie de/data/filessans.tmp/, en liens physiques avec l’instantané précédent quand le système de fichiers le permet) ;config/(copie brute) ;blobs.tsv(contenus attendus) ;backup.json(métadonnées) ;SHA256SUMS(manifeste).
Le dossier est écrit sous
<nom>.partialpuis renommé : un instantané incomplet ne passe jamais pour complet. -
Cohérence : verrou consultatif
file.gcpartagé, tenu pendant toute la plage. Ensuitepg_dumpd’abord, puis la listefile_blob, puis la copie des fichiers, puis le contrôle que chaque contenu attendu est présent avec la bonne taille (ADR 0012). -
Rétention : 7 quotidiennes, 4 hebdomadaires, 6 mensuelles (variables
BACKUP_KEEP_*). Le plus récent n’est jamais supprimé. Un fichierKEEPprotège un instantané. La rotation n’a lieu qu’après une sauvegarde réussie. -
Restauration :
restore.sh <instantané|latest>refuse une base ou un dossier de fichiers non vides. Aucune option destructive : vider l’existant est un geste explicite de l’utilisateur, documenté. Le script vérifie l’instantané, lancepg_restore --single-transaction --exit-on-error, copie les fichiers et revérifie (tailles de tous les contenus, SHA-256 d’un échantillon).config/est restaurée à côté, dans./config.restauree, jamais par-dessus./config. -
Restauration testée :
- un test d’intégration Testcontainers exécute les vrais scripts et compare la source et la restauration, table par table et fichier par fichier, à chaque
./mvnw -pl backend verify; - en production, un test de restauration hebdomadaire dans une base jetable
<base>_restore_test; - une procédure manuelle dans
docs/sauvegardes.md.
- un test d’intégration Testcontainers exécute les vrais scripts et compare la source et la restauration, table par table et fichier par fichier, à chaque
-
Échec visible :
- codes de sortie 0 (réussite), 1 (échec) et 2 (instantané complet avec anomalies) ;
/backups/status.json, sur une ligne, lu par lehealthcheckdu conteneur ;/backups/last-error.log.
-
Reporté au déploiement : copie hors site, chiffrement, alerte, emplacement des sauvegardes sur un autre disque, droits sur le dossier de l’hôte.
Contexte
Le cahier classe les sauvegardes en MVP (l. 200 : « L’application est exposée sur internet et contient des documents personnels : l’authentification et les sauvegardes font partie du MVP »). La l. 210 en donne le contenu : « Sauvegardes | Dump PostgreSQL + fichiers + dossier config/ planifiés, restauration testée ». Le jalon 1 cite « OIDC, déploiement, sauvegardes, export ». Ligne du backlog : « Sauvegardes planifiées (dump PostgreSQL + /data + config/) et procédure de restauration testée ».
Obligations héritées :
| ADR | Obligation |
|---|---|
| 0012 | pg_dump d’abord, puis copie de <racine> (app.files.path, /data/files) sans .tmp/. Les contenus sont immuables et jamais supprimés avant la purge : une copie prise après le dump contient tout ce que le dump référence. La purge devra être exclue de la plage de sauvegarde. La restauration testée vérifie sha256sum sur un échantillon. |
| 0012 | Les répertoires de fichiers sont créés à la première écriture : /data/files peut ne pas exister. |
| 0018 | L’export est distinct de la sauvegarde. Il exclut la corbeille et l’historique node_event, « que la sauvegarde couvre ». L’export planifié est reporté « à reconsidérer avec la tranche sauvegardes ». |
État de l’exploitation (docker-compose.yml, Dockerfile) :
- service
db(postgres:17-alpine, volumedb-data, superutilisateursecondbrain, mot de passe${DB_PASSWORD}) ; - service
app(profilapp) :./config:/config:roetapp-data:/data, fichiers dans/data/files. L’image JRE tourne sous l’utilisateurapp(uid 10001) et ne contient aucun client PostgreSQL ; - en développement, le backend tourne sur l’hôte et écrit ses fichiers dans
./data/files(../data/filesrelatif àbackend/), pas dans le volumeapp-data.
Le schéma utilise l’extension unaccent (« trusted »), une configuration de recherche public.french_unaccent et une colonne générée node.search (V7). Une sonde a vérifié qu’un pg_restore de ces objets dans une base neuve redonne un contenu identique (md5 des lignes, search compris).
La tranche « Déploiement » (image, Caddy, OIDC Authentik) vient après celle-ci. La sauvegarde doit donc fonctionner avec le docker-compose.yml actuel, en développement sous Windows comme sur un serveur Linux, sans présumer de l’hébergement.
Décision
Où tourne la sauvegarde : un service compose dédié
Un dossier backup/ à la racine du dépôt (au même niveau que config/, backend/ et frontend/) :
backup/
Dockerfile
bin/
common.sh fonctions partagées : journal, configuration, état, verrous, horodatage
backup.sh une sauvegarde (instantané complet, puis rotation)
verify.sh vérification hors ligne d'un instantané
restore.sh restauration sur base et dossier vides
prune.sh rotation (appelée par backup.sh, utilisable seule avec --dry-run)
scheduler.sh boucle de planification (commande par défaut du service)
healthcheck.sh état de santé lu dans status.json
restore-test.sh test de restauration dans une base jetable (tranche 4)
# Sauvegarde et restauration (ADR 0019). Même version majeure que le service db : pg_dump doit être >= serveur.
FROM postgres:17-alpine
RUN apk add --no-cache rsync jq
COPY bin/ /opt/sb-backup/
# Fins de ligne CRLF possibles sur un poste Windows mal configuré, droits d'exécution perdus par Git sous Windows
RUN sed -i 's/\r$//' /opt/sb-backup/*.sh && chmod 0755 /opt/sb-backup/*.sh
ENV PATH="/opt/sb-backup:${PATH}" \
BACKUP_DIR=/backups BACKUP_FILES_DIR=/data/files BACKUP_CONFIG_DIR=/config
# Pas de serveur PostgreSQL dans ce conteneur
ENTRYPOINT []
CMD ["scheduler.sh"]
- Base
postgres:17-alpine:pg_dumpetpg_restore17.x,tzdata,flock,mkfifo,shuf,sha256sumetdate -dsont présents (vérifié). L’image est déjà téléchargée pourdbet pour les tests. Seulsrsync(copie avec--link-dest) etjq(JSON écrit et lu sans échappement fait main) sont ajoutés. - Scripts POSIX
sh(ash de BusyBox) avecset -eu -o pipefail: pas de bash. Ils tournent toujours dans le conteneur, jamais sous Windows..gitattributesforce déjàeol=lf, et leseddu Dockerfile protège d’un dépôt cloné autrement. - Le conteneur tourne en root : il doit lire
/dataquel que soit le propriétaire des fichiers, écrire dans/backupset, à la restauration, rendre les fichiers à l’uid de l’application. Ses montages de données sont en lecture seule pour la sauvegarde. Les scripts posentumask 077: les instantanés (documents personnels en clair) ne sont lisibles que par root.
Service compose (ajouté en tranche 1, complété en tranche 3) :
backup:
build: ./backup
image: second-brain-backup:local
profiles: ["app", "backup"]
init: true # tini en PID 1 : SIGTERM traité, pas de zombie
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGPORT: "5432"
PGDATABASE: secondbrain
PGUSER: secondbrain
PGPASSWORD: ${DB_PASSWORD:-secondbrain}
TZ: ${BACKUP_TZ:-Europe/Brussels}
BACKUP_TIME: ${BACKUP_TIME:-03:00}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-7}
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-4}
BACKUP_KEEP_MONTHLY: ${BACKUP_KEEP_MONTHLY:-6}
BACKUP_VERIFY_SAMPLE: ${BACKUP_VERIFY_SAMPLE:-20}
BACKUP_RESTORE_TEST_DAYS: ${BACKUP_RESTORE_TEST_DAYS:-7}
volumes:
# app-data (image complète) ou ./data (backend lancé sur l'hôte en développement)
- ${BACKUP_DATA_SOURCE:-app-data}:/data:ro
- ./config:/config:ro
- ${BACKUP_PATH:-./backups}:/backups
healthcheck:
test: ["CMD", "healthcheck.sh"]
interval: 5m
timeout: 10s
start_period: 1h
retries: 1
restore:
image: second-brain-backup:local
build: ./backup
profiles: ["restore"]
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGPORT: "5432"
PGDATABASE: secondbrain
PGUSER: secondbrain
PGPASSWORD: ${DB_PASSWORD:-secondbrain}
RESTORE_FILES_DIR: /data/files
RESTORE_CONFIG_DIR: /config-restauree
RESTORE_FILES_OWNER: ${RESTORE_FILES_OWNER-10001:10001}
volumes:
- ${BACKUP_DATA_SOURCE:-app-data}:/data # lecture-écriture : cible de la restauration
- ${BACKUP_PATH:-./backups}:/backups:ro
- ${RESTORE_CONFIG_PATH:-./config.restauree}:/config-restauree
entrypoint: ["restore.sh"]
- Profils :
backupfait partie du profilapp, doncdocker compose --profile app up -dlance aussi les sauvegardes ;docker compose up -d db, la commande habituelle en développement, ne le lance pas ;restoren’est démarré que pardocker compose run --rm restore …, qui active son profil implicitement.
- Destination :
- par défaut
./backupsà côté dudocker-compose.yml, un montage de l’hôte : visible, copiable, et l’endroit naturel de la future copie hors site ; BACKUP_PATHla déplace, par exemple vers un autre disque (tranche déploiement) ;- un volume nommé aurait caché les sauvegardes dans la machine virtuelle de Docker Desktop.
- par défaut
- Source des fichiers :
BACKUP_DATA_SOURCEvautapp-data(volume nommé, image complète) ou./data(montage de l’hôte, développement). La forme courte de compose accepte les deux. - Une seule image pour deux services : même
image:et mêmebuild:, donc construite une fois. - Sauvegarde manuelle :
docker compose run --rm backup backup.sh. Elle partage le verrou de la sauvegarde planifiée : si une sauvegarde est déjà en cours, elle s’arrête avec un échec explicite. - L’image
postgresdéclareVOLUME /var/lib/postgresql/data. Le servicebackupgarde donc un volume anonyme vide. Il est sans effet et supprimé parrun --rm.
Configuration
Toutes les variables ont une valeur par défaut dans common.sh. Une valeur invalide (BACKUP_KEEP_DAILY=0, heure 25:00, nombre non entier) fait échouer le script avec l’état failed et le message « configuration invalide : … ».
| Variable | Défaut | Rôle |
|---|---|---|
PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD |
— | Variables standard de libpq, lues par psql, pg_dump et pg_restore |
BACKUP_DIR |
/backups |
Racine des instantanés |
BACKUP_FILES_DIR |
/data/files |
Racine des contenus (ADR 0012) |
BACKUP_CONFIG_DIR |
/config |
Définitions |
BACKUP_TIME |
03:00 |
Heure quotidienne, dans le fuseau TZ |
BACKUP_KEEP_DAILY / _WEEKLY / _MONTHLY |
7 / 4 / 6 |
Rotation (DAILY ≥ 1, les autres ≥ 0) |
BACKUP_VERIFY_SAMPLE |
20 |
Contenus dont le SHA-256 est recalculé (all = tous) |
BACKUP_LOCK_TIMEOUT |
600 |
Attente maximale, en secondes, du verrou file.gc |
BACKUP_RETRY_MINUTES |
60 |
Délai avant de retenter après un échec |
BACKUP_CHECK_INTERVAL |
300 |
Période, en secondes, de la boucle de planification |
BACKUP_MAX_AGE_HOURS |
26 |
Âge maximal de la dernière réussite pour le healthcheck |
BACKUP_RESTORE_TEST_DAYS |
7 |
Période du test de restauration (0 = jamais) |
RESTORE_FILES_DIR |
/data/files |
Cible des contenus à la restauration |
RESTORE_CONFIG_DIR |
/config-restauree |
Cible de la copie de config/ |
RESTORE_FILES_OWNER |
10001:10001 |
chown -R des fichiers restaurés (vide = pas de chown) |
BACKUP_NOW |
— | Tests uniquement : heure courante en secondes epoch |
BACKUP_TEST_PAUSE_AFTER_DUMP |
— | Tests uniquement : pause, en secondes, après le dump, verrou file.gc tenu |
.env.example gagne une section « Sauvegardes » commentée avec ces variables.
Instantané
/backups/
status.json état de la dernière exécution (une ligne JSON)
last-error.log sortie d'erreur de la dernière exécution en échec
.lock verrou flock (une exécution à la fois)
20261015T010000Z/
backup.json
SHA256SUMS
db.dump pg_dump --format=custom
blobs.tsv "<sha256>\t<taille>" par ligne de file_blob, trié
files/ab/cd/<sha256> même disposition que /data/files, sans .tmp/
config/aspects/facture.yaml … copie brute de /config
missing-files.tsv seulement si des contenus attendus manquent (état warning)
KEEP facultatif, posé à la main : jamais supprimé par la rotation
20261016T010000Z.partial/ exécution en cours ou interrompue
-
Nom : l’heure de début en UTC,
AAAAMMJJTHHMMSSZ. Le nom est triable, sans:(compatible Windows) et sans ambiguïté au changement d’heure. -
files/:- le nom de chaque contenu est son SHA-256, il se vérifie donc lui-même. Le manifeste ne le répète pas, ce qui le garde petit avec 100 000 contenus ;
- les contenus sont immuables : copier en liens physiques avec l’instantané précédent (
rsync --link-dest) est sans risque, et chaque instantané reste un arbre complet, autonome, supprimable parrm -rfsans toucher aux autres ; - au début de chaque exécution,
backup.shtestelndans/backups. Si le lien échoue (partage SMB, certains NAS), il copie tout et journalise « liens physiques indisponibles : copie complète ». La sonde a montré que les liens fonctionnent sur un volume Docker comme sur un montage Windows de Docker Desktop ; - si
/data/filesn’existe pas (aucun fichier encore déposé),files/est vide.
-
config/: copie en clair, plutôt qu’une archive, pour qu’elle reste lisible et comparable pardiff -r. -
blobs.tsv: la listefile_bloblue après le dump. Elle contient au moins toutes les lignes du dump (voir Cohérence). Elle permet de vérifier un instantané sans base de données. -
SHA256SUMS: formatsha256sum, chemins relatifs. Il couvredb.dump,blobs.tsv,backup.jsonet chaque fichier deconfig/. On le contrôle parsha256sum -c SHA256SUMS. -
backup.json(informatif, écrit parjq) :{ "formatVersion": 1, "snapshot": "20261015T010000Z", "startedAt": "2026-10-15T01:00:00Z", "finishedAt": "2026-10-15T01:00:42Z", "state": "ok", "postgres": { "serverVersionNum": 170011, "pgDump": "17.11" }, "schemaVersion": "8", "database": { "dumpBytes": 1234567 }, "files": { "blobs": 412, "bytes": 1803452211, "hardLinked": true, "verifiedSample": 20 }, "config": { "files": 11 }, "missingFiles": 0 }schemaVersionest la plus haute version réussie deflyway_schema_history.
backup.sh : ordre des opérations
backup.sh une sauvegarde complète puis la rotation ; code 0, 1 ou 2
-
Une exécution à la fois :
flock -n /backups/.lock. Si le verrou est déjà pris, on sort en échec avec « une sauvegarde est déjà en cours ».status.jsonn’est pas modifié, puisque l’autre exécution l’écrit. -
status.jsonpasse àrunning(lastAttemptAt). Les dossiers*.partiallaissés par une exécution interrompue sont supprimés. -
Création de
<nom>.partial/. -
Verrou
file.gcpartagé, tenu sur une session dédiée. Unpsqlen arrière-plan lit ses commandes dans une FIFO, et le script garde la FIFO ouverte sur le descripteur 3 :mkfifo "$work/lock.in" psql -X -q -At -v ON_ERROR_STOP=1 < "$work/lock.in" > "$work/lock.out" 2>&1 & exec 3> "$work/lock.in" printf "set lock_timeout = '%ss';\nselect pg_advisory_lock_shared(hashtext('file.gc'));\n\\\\! touch %s\n" \ "$BACKUP_LOCK_TIMEOUT" "$work/locked" >&3 # attente de "$work/locked" ; si psql s'arrête avant (lock_timeout, base injoignable) : échecLa fin du script, normale ou brutale, ferme le descripteur 3.
psqllit alors la fin de fichier et se termine, et la session libère le verrou. Le signal passe par\! touchet non par la sortie depsql, mise en mémoire tampon.lock_timeouts’applique aux verrous consultatifs. La sonde a vérifié ces trois points.Le verrou partagé est compatible avec les dépôts de fichiers, qui prennent le même verrou en mode partagé (ADR 0012) : la sauvegarde ne bloque jamais l’application. Il exclut la purge du jalon 3, qui le prendra en mode exclusif : c’est l’exclusion demandée par l’ADR 0012.
-
pg_dump --format=custom --file=db.dump. Le dump lit un instantané cohérent de toute la base,node_eventet corbeille compris. -
blobs.tsv:copy (select sha256, size from file_blob order by sha256) to stdout, lu après le dump. -
Copie des fichiers :
rsync -a --exclude=/.tmp/ [--link-dest=<instantané précédent>/files] /data/files/ <partial>/files/. -
Libération du verrou
file.gc(fermeture du descripteur 3). -
Copie de
config/(cp -a /config/. <partial>/config/). Un dossier/configabsent est un échec : le montage est mal configuré. -
Contrôles :
- chaque ligne de
blobs.tsva son fichier dans<partial>/files/avec la bonne taille. Les manquants vont dansmissing-files.tsv, et l’état devientwarning; pg_restore --list db.dump: la table des matières du dump est lisible ;- SHA-256 de
BACKUP_VERIFY_SAMPLEcontenus tirés au hasard (shuf), égal à leur nom.
Un SHA-256 faux est un échec : la copie est abîmée.
- chaque ligne de
-
backup.json, puisSHA256SUMS. -
mv <nom>.partial <nom>, un renommage atomique dans le même dossier. -
status.jsonpasse àokouwarning. -
Rotation (
prune.sh), seulement si l’instantané est complet. Une erreur de rotation est journalisée et fait passer l’état àwarningsans supprimer l’instantané. -
Code de sortie : 0 (
ok), 2 (warning).
En cas d’erreur (piège EXIT) :
<nom>.partialest supprimé ;- les 200 dernières lignes de la sortie d’erreur vont dans
last-error.log; status.jsonpasse àfailedavec le message de l’étape ;- le code de sortie est 1.
Les instantanés existants ne sont jamais touchés par une exécution en échec.
Pourquoi la copie après le dump suffit : FileStore.commit renomme le contenu à sa place définitive avant le commit de la ligne file_blob (ADR 0012).
- Toute ligne visible dans le dump, ou dans
blobs.tsvlu plus tard, a donc son contenu sur le disque avant le début de la copie. - Rien n’est supprimé pendant la plage, puisque la purge est exclue par
file.gc. - Les contenus déposés après le dump sont copiés en plus, sans ligne : ils sont sans danger, comme des orphelins.
- Les fichiers de
.tmp/ne sont jamais référencés.
Journal : une ligne par étape sur la sortie standard (docker compose logs backup), avec l’heure UTC, le script et le niveau. Les lignes contiennent les compteurs, tailles, durées et noms d’instantanés, jamais un titre ni un contenu. Les SHA-256 manquants vont dans missing-files.tsv, pas dans le journal.
status.json et état de santé
Une ligne JSON, écrite par jq dans un fichier temporaire puis renommée :
{"formatVersion":1,"state":"ok","message":"Sauvegarde 20261015T010000Z terminée","lastAttemptAt":"2026-10-15T01:00:00Z","lastAttemptEpoch":1792026000,"lastSuccessAt":"2026-10-15T01:00:42Z","lastSuccessEpoch":1792026042,"lastSnapshot":"20261015T010000Z","durationSeconds":42,"lastRestoreTestAt":null,"lastRestoreTestState":null}
-
statevautrunning,ok,warningoufailed.lastSuccess*reste celui de la dernière exécutionokouwarning. -
healthcheck.shrenvoie 0 si et seulement si :status.jsonexiste ;statevautokourunning;lastSuccessEpochdate de moins deBACKUP_MAX_AGE_HOURS;lastRestoreTestStatene vaut pasfailed.
Sinon il renvoie 1 et affiche la raison.
warningest donc « non sain » : un contenu manquant à la source est une perte de données à traiter, pas un détail.docker compose psafficheunhealthy. Le brancher sur une alerte relève du déploiement. -
Le fichier est conçu pour être lu plus tard par l’application (page « Données ») ou par une sonde externe, sans dépendre des scripts.
Planification : scheduler.sh
Une boucle, sans cron. BusyBox crond ne transmet pas l’environnement aux tâches et ne rattrape pas une exécution manquée. supercronic serait un binaire de plus.
toutes les BACKUP_CHECK_INTERVAL secondes :
si est_due(maintenant) : backup.sh (code de sortie journalisé, la boucle continue)
si sauvegarde réussie et test de restauration dû : restore-test.sh (tranche 4)
est_due(maintenant) :
créneau = BACKUP_TIME du jour dans TZ si maintenant >= celui-ci, sinon celui de la veille
si aucune réussite connue (status.json absent ou lastSuccessEpoch nul) :
due, sauf échec depuis moins de BACKUP_RETRY_MINUTES
sinon due si lastSuccessEpoch < créneau
et (lastAttemptEpoch < créneau ou maintenant - lastAttemptEpoch >= BACKUP_RETRY_MINUTES)
- Au premier démarrage, une sauvegarde part immédiatement : le
healthchecka tout de suite un sens. - Une sauvegarde manquée (conteneur arrêté à 3 h) est rattrapée au redémarrage.
- Un échec est retenté toutes les heures jusqu’au créneau suivant.
- Le créneau de la veille est calculé par
date -d "<date de la veille> $BACKUP_TIME", pas par « −86 400 s », pour rester juste aux changements d’heure. scheduler.sh --is-dueaffichedueounot-dueet sort. AvecBACKUP_NOW, c’est le point d’entrée des tests.init: true(tini) :docker compose stopinterrompt proprement. Un instantané en cours reste en.partialet sera nettoyé à l’exécution suivante.
Rotation : prune.sh
prune.sh [--dry-run] liste (et supprime sans --dry-run) les instantanés hors politique
Sur les instantanés complets (nom AAAAMMJJTHHMMSSZ, sans .partial), du plus récent au plus ancien :
- quotidien : le plus récent de chacun des
BACKUP_KEEP_DAILYderniers jours distincts (clé%Y%m%d, en UTC) ; - hebdomadaire : le plus récent de chacune des
BACKUP_KEEP_WEEKLYdernières semaines ISO distinctes (%G-W%V) ; - mensuel : le plus récent de chacun des
BACKUP_KEEP_MONTHLYderniers mois distincts (%Y-%m) ; - le plus récent est toujours conservé, ainsi que tout instantané qui contient un fichier
KEEP; - les autres sont supprimés (
rm -rf). Les liens physiques gardent les contenus encore utilisés par un instantané conservé.
Les clés sont calculées en UTC, à partir du nom. Avec une sauvegarde à 3 h, heure de Bruxelles, le jour UTC est le même. Une sauvegarde manuelle prise plus tôt le même jour que la planifiée est remplacée à la rotation suivante, sauf si on la protège par KEEP (documenté).
Avec les valeurs par défaut, on garde environ 15 instantanés (7 + 3 + 5, une fois retirés les recouvrements) et 6 mois d’historique. L’espace occupé vaut une fois les fichiers, plus les fichiers ajoutés depuis, plus environ 15 dumps compressés.
verify.sh : vérification hors ligne
verify.sh <instantané|latest> [--sample N|all] code 0 ou 1
- Le nom désigne un instantané complet (pas de
.partial). sha256sum -c SHA256SUMSpasse.pg_restore --list db.dumppasse.- Chaque ligne de
blobs.tsvabsente demissing-files.tsva son fichier avec la bonne taille. - Le SHA-256 de l’échantillon est égal au nom du fichier.
Le script n’a pas besoin de la base. Il est appelé par restore.sh avant toute écriture, et il est utilisable sur une copie hors site (avec docker run).
restore.sh : restauration sur une base vide
restore.sh <instantané|latest> [--sample N|all] code 0 ou 1
On passe un nom d’instantané, jamais un chemin : Git Bash réécrit les arguments qui commencent par / en chemins Windows.
-
verify.shde l’instantané. Un échec arrête tout, et rien n’a été écrit. -
Base cible vide : aucune relation hors
pg_catalog,information_schemaet schémaspg_toast*/pg_temp*, et aucune extension autre queplpgsql. Sinon : « La basen’est pas vide : restauration refusée. Voir docs/sauvegardes.md ». Une application démarrée sur la base neuve y aurait déjà joué Flyway : ce refus protège aussi de cet oubli. -
Dossier de fichiers cible vide :
RESTORE_FILES_DIRabsent, ou vide en dehors d’un.tmp/vide. Sinon, refus. -
Version : si la version majeure du serveur cible est inférieure à
backup.json.postgres.serverVersionNum, refus avec un message clair (sans cela,pg_restoreéchouerait de façon obscure). -
pg_restore --single-transaction --exit-on-error --no-owner --no-privileges -d "$PGDATABASE" db.dump, puisanalyze. Avec une seule transaction, un échec laisse la base vide et le script peut être relancé. La colonne généréenode.searchest recalculée à la restauration. -
rsync -a <instantané>/files/ "$RESTORE_FILES_DIR"/(copie réelle : la source est en lecture seule et sur un autre système de fichiers). Ensuite, siRESTORE_FILES_OWNERn’est pas vide,chown -RdeRESTORE_FILES_DIRet de son parent immédiat : le volumeapp-datarecréé par ce conteneur appartiendrait sinon à root, et l’application (uid 10001) ne pourrait plus y écrire. -
Contrôle après restauration, sur la base restaurée :
- chaque ligne de
file_bloba son fichier avec la bonne taille ; - le SHA-256 de l’échantillon est égal au nom ;
- on affiche les compteurs (
nodehors corbeille, en corbeille,file_blob,node_event) etschemaVersion.
Une anomalie fait échouer le script. Base et fichiers restent alors en place pour examen, et le message indique comment repartir de zéro.
- chaque ligne de
-
config/: copiée dansRESTORE_CONFIG_DIR(./config.restaureesur l’hôte), si ce dossier est vide. Sinon, le script le signale et n’écrit rien. Jamais par-dessus./config: les définitions sont aussi versionnées dans Git, et l’utilisateur choisit (diff -r config config.restauree). -
Le script affiche les étapes suivantes : démarrer l’application, vérifier quelques nœuds et fichiers.
Il n’y a aucune option destructive (--force, --drop). Repartir d’une base vide est un geste explicite de l’utilisateur sur les volumes, décrit dans la procédure.
Procédure documentée : docs/sauvegardes.md
Le document, en français, est référencé depuis le README (sections « Déploiement » et « Organisation »). Il couvre :
- Ce qui est sauvegardé, et ce qui ne l’est pas :
.env(mot de passe de la base, secret OIDC) : à garder dans un gestionnaire de mots de passe ;- le dépôt Git lui-même ;
- la différence avec l’export (ADR 0018).
- Activer :
- image complète :
docker compose --profile app up -d; - développement :
BACKUP_DATA_SOURCE=./data docker compose --profile backup up -d backup.
- image complète :
- Sauvegarde manuelle :
docker compose run --rm backup backup.sh. Protéger un instantané :touch backups/<nom>/KEEP. - Surveiller :
docker compose ps(healthy/unhealthy) ;backups/status.json;docker compose logs backup.
- Vérifier un instantané :
docker compose run --rm backup verify.sh latest --sample all. - Restaurer (image complète) :
- si possible, sauvegarder l’état actuel (
docker compose run --rm backup backup.sh) ; docker compose --profile app down(les volumes sont conservés) ;docker volume rm <projet>_db-data <projet>_app-data(destructif, nom du projet donné pardocker volume ls) ;docker compose up -d db(base neuve et vide) ;docker compose run --rm restore latest(ou<nom>) ;- comparer
./config.restaureeet./config, remplacer si besoin ; docker compose --profile app up -d, puis vérifier dans l’interface.
- si possible, sauvegarder l’état actuel (
- Restaurer en développement :
- même chose avec
BACKUP_DATA_SOURCE=./data, après avoir vidé./data/fileset le volumedb-data; - sous Linux,
RESTORE_FILES_OWNER=$(id -u):$(id -g)pour que le backend lancé sur l’hôte puisse écrire ; - sous Windows,
RESTORE_FILES_OWNER=(vide).
- même chose avec
- Exercice de restauration : une fois par trimestre, restaurer sur une autre machine depuis la copie hors site (quand elle existera) et ouvrir quelques nœuds. Le test hebdomadaire automatique ne vérifie ni la copie hors site ni la procédure humaine.
- Monter de version PostgreSQL : changer en même temps l’image de
dbet leFROMdebackup/Dockerfile(testBackupImageVersionTest). Une montée de version majeure passe par une sauvegarde, puis une restauration dans le nouveau serveur.
Test de restauration périodique : restore-test.sh (tranche 4)
restore-test.sh <instantané|latest> code 0 ou 1
- Base jetable :
"$PGDATABASE"_restore_test.- Le script refuse tout autre nom calculé, et refuse si ce nom est égal à
PGDATABASE. - Il lance
dropdb --if-exists, puiscreatedb, puispg_restore --single-transaction --exit-on-error --no-owner --no-privileges. - Il contrôle ensuite les lignes de
file_blobcontre<instantané>/files(tailles de toutes les lignes, SHA-256 de l’échantillon), puis lancedropdb, y compris en cas d’échec (piège).
- Le script refuse tout autre nom calculé, et refuse si ce nom est égal à
- Appelé par
scheduler.shaprès une sauvegardeok, si le dernier test date de plus deBACKUP_RESTORE_TEST_DAYSjours. Il écritlastRestoreTestAtetlastRestoreTestState(ok/failed) dansstatus.json. Un échec rend lehealthchecknon sain. - Il ne touche ni à la base principale ni à
/data: les fichiers sont contrôlés dans l’instantané, sans copie. - Il demande le droit
CREATEDB:secondbrainest superutilisateur dans l’image officielle. Pendant le test, l’espace disque de la base est occupé une seconde fois.
Ce qui ne change pas
- Aucune migration, aucun endpoint, aucun composant front, aucun événement. Une sauvegarde n’écrit rien dans la base, sinon des verrous consultatifs.
- Format YAML, méta-schémas,
CLAUDE.mdet skills inchangés. Le skillbackend-conventionsgagne une ligne : « Une nouvelle donnée persistante hors de la base ou hors de/data/files(dossier, volume) doit être ajoutée à la sauvegarde (ADR 0019) ». .gitignoregagne/backups/et/config.restauree/..dockerignoregagnebackups,config.restaureeetdata: sans cela, les sauvegardes et les fichiers de développement seraient envoyés dans le contexte de construction de l’image de l’application, qui n’exclut aujourd’hui pasdata/.
Tests
Les tests sont dans le module backend, paquet com.anorys.secondbrain.backup (tests seulement, aucun code de production). Ils tournent avec ./mvnw -pl backend verify, comme les autres tests d’intégration, puisque le projet n’a pas de CI. Le répertoire de travail de Failsafe est backend/. Les scripts tournent toujours dans un conteneur : les tests passent aussi sous Windows.
Outils communs (BackupContainers, classe de test) :
final class BackupContainers {
/** Image construite depuis ../backup, une fois par exécution des tests. */
static final ImageFromDockerfile IMAGE =
new ImageFromDockerfile("second-brain-backup-test", false).withFileFromPath(".", Path.of("../backup"));
/** Conteneur de sauvegarde au repos (sleep infinity) sur le réseau donné, PG* pointant vers pgAlias. */
static GenericContainer<?> backupContainer(Network network, String pgAlias, PostgreSQLContainer pg);
/** Copie un dossier de l'hôte dans le conteneur (pas de montage : portable sous Windows et Docker distant). */
static void copyDirectory(GenericContainer<?> c, Path hostDir, String containerDir);
/** Lance un script avec des variables d'environnement supplémentaires ; renvoie code, sortie et erreur. */
static ExecResult run(GenericContainer<?> c, Map<String, String> env, String... command);
/** "chemin relatif<TAB>sha256" de chaque fichier régulier sous dir, trié, .tmp/ exclu. */
static List<String> fileDigests(GenericContainer<?> c, String dir);
/** md5 des lignes de chaque table du schéma public, triées par leur forme texte ; plus les séquences. */
static Map<String, String> tableDigests(DataSource ds);
}
tableDigests exécute, pour chaque table de information_schema.tables du schéma public :
select md5(coalesce(string_agg(t::text, E'\n' order by t::text), '')) from <table> t
Elle ajoute pg_sequences (nom et last_value) et pg_extension (nom et version). La comparaison est générique : une table ajoutée par une migration future est couverte sans changer le test.
Tranche 1 :
-
BackupImageVersionTest(unitaire) : l’image dedbdansdocker-compose.ymlet leFROMdebackup/Dockerfilesont identiques. -
BackupScriptsLintIT:koalaman/shellcheck-alpine(version épinglée) en mode-s shsurbackup/bin/*.sh, sans avertissement. Il garde les scripts POSIX pour l’ash de BusyBox. -
BackupIT(@SpringBootTest, profildev, PostgreSQL sur unNetworkavec l’aliasdb,app.files.pathdans un dossier temporaire). Le jeu de données est créé par les services :- une facture avec trois aspects, des tags saisis et du corps, un lien wiki, un
node-ref, un lien typé et un fichier PDF ; - un même contenu rattaché à deux nœuds ;
- un nœud dans l’inbox ;
- un nœud en corbeille ;
- un changement de type, pour avoir des
node_eventvariés ; - un fichier abandonné
.tmp/orphelin.
Les fichiers et
../configsont copiés dans le conteneur après la création du jeu de données. Cas :- instantané nominal :
- code 0,
status.json.state = ok; - nom au bon format, aucun
.partial; sha256sum -c SHA256SUMSetpg_restore --listpassent ;blobs.tsvest égal àfile_blob;fileDigests(files/)est égal à la source sans.tmp/;config/est identique à../config;backup.jsona ses compteurs etschemaVersion;
- code 0,
/data/filesabsent : code 0,files/vide,blobs.tsvvide ;- contenu manquant à la source (fichier supprimé avant la sauvegarde) : code 2, état
warning,missing-files.tsvcontient son SHA-256, l’instantané est complet ; - base injoignable (
PGHOST=inconnu) : code 1, étatfailed,last-error.logprésent, aucun nouvel instantané, aucun.partial, les instantanés précédents sont intacts ; - verrou
file.gctenu en exclusif par une connexion JDBC du test (pg_advisory_lock) avecBACKUP_LOCK_TIMEOUT=2: code 1 en moins de 10 s, message qui citefile.gc. Après libération, une nouvelle exécution réussit ; - dépôt pendant la sauvegarde :
backup.shest lancé dans un thread du test avecBACKUP_TEST_PAUSE_AFTER_DUMP=5. Pendant la pause, verrou partagé tenu :pg_try_advisory_lock(hashtext('file.gc'))(exclusif) renvoiefalsedepuis une connexion du test ;- un dépôt par
NodeFileServiceréussit. Son contenu est copié ou non selon le moment, mais l’instantané reste complet et cohérent (code 0) ;
- exécutions concurrentes :
flock /backups/.lock sleep 30 &dans le conteneur, puisbackup.sh: code 1, « déjà en cours » ; verify.sh: 0 sur l’instantané ; 1 après modification d’un octet d’un contenu (avec--sample all) ; 1 après modification d’un octet dedb.dump; 1 après suppression d’un contenu deblobs.tsvdansfiles/.
- une facture avec trois aspects, des tags saisis et du corps, un lien wiki, un
Tranche 2 : RestoreIT, avec le même jeu de données que BackupIT (méthode de création partagée), un second PostgreSQL vide (alias db-restore) et RESTORE_FILES_DIR=/restored/files, RESTORE_CONFIG_DIR=/restored/config, RESTORE_FILES_OWNER= :
- aller-retour :
backup.sh, puisrestore.sh latestavecPGHOST=db-restore: code 0 ;tableDigests(source) == tableDigests(restaurée), tables, séquences et extensions comprises (node.searchrecalculé égal,node_event, corbeille,flyway_schema_history) ;Flyway.configure().dataSource(restaurée).locations("classpath:db/migration").load().validate()passe ;fileDigests(/data/files)est égal àfileDigests(/restored/files);/restored/configest identique à../config;
- base non vide : un second
restore.sh latestdonne le code 1 et le message attendu. La base et les fichiers restaurés sont inchangés (mêmes empreintes) ; - dossier de fichiers non vide (base vide, fichier présent) : code 1, la base reste vide ;
- instantané abîmé (un octet de
db.dump) : code 1 à l’étape de vérification, la base cible reste vide et/restored/filesn’existe pas ; RESTORE_FILES_OWNER=10001:10001: les fichiers restaurés appartiennent à l’uid 10001 (stat -c %u) ;- config déjà présente dans
RESTORE_CONFIG_DIR: rien n’est écrit, le message le signale, code 0.
Tranche 3 : BackupScheduleIT (conteneur seul, plus un PostgreSQL pour les liens physiques) :
scheduler.sh --is-due, avecBACKUP_NOW,TZ=Europe/Brusselset unstatus.jsonpréparé :- aucun état :
due; - réussite hier à 3 h, maintenant 2 h 59 :
not-due; - maintenant 3 h 00 :
due; - échec à 3 h 00, maintenant 3 h 30 :
not-due; maintenant 4 h 00 :due; - réussite il y a trois jours, maintenant 1 h :
due(rattrapage) ; - nuit du passage à l’heure d’hiver (25 octobre 2026) : créneau correct.
- aucun état :
prune.shsur des dossiers vides créés parmkdir(60 instantanés quotidiens sur deux mois, des doublons dans la journée, un.partial, unKEEPancien, un dossier au nom invalide). L’ensemble restant est égal à celui que calcule une implémentation Java indépendante de la politique (IsoFields.WEEK_OF_WEEK_BASED_YEAR). Variantes :KEEP_WEEKLY=0,KEEP_MONTHLY=0,KEEP_DAILY=1.--dry-runne supprime rien. Le dossier au nom invalide n’est jamais touché.- Liens physiques : deux
backup.shsuccessifs, le même contenu a le même inode (stat -c %i) dans les deux instantanés ; après la suppression du premier, le second reste vérifiable (verify.sh --sample all). healthcheck.sh: 0 avecokrécent ; 1 avecfailed, avecwarning, avec unokplus vieux queBACKUP_MAX_AGE_HOURS(viaBACKUP_NOW), sansstatus.json, et aveclastRestoreTestState = failed.- Configuration invalide (
BACKUP_KEEP_DAILY=0,BACKUP_TIME=25:00) : code 1, étatfailed, message explicite.
Tranche 4 : dans RestoreIT, restore-test.sh latest donne le code 0. La base secondbrain_restore_test n’existe plus ensuite (pg_database) et status.json porte lastRestoreTestState = ok. Avec un db.dump abîmé : code 1, la base jetable est supprimée quand même, et l’état vaut failed. Avec PGDATABASE vide : refus.
Vérification manuelle de chaque tranche, sous Windows avec Git Bash :
docker compose --profile backup build backup;BACKUP_DATA_SOURCE=./data docker compose run --rm backup backup.sh;- examiner
backups/; - pour la tranche 2, la procédure de restauration complète sur une copie.
Découpage
-
Sauvegarde manuelle (
feat(sauvegardes): image et script de sauvegarde (dump, fichiers, config, manifeste)) :backup/Dockerfile,common.sh,backup.sh(sans--link-destni rotation) etverify.sh;- service compose
backup, profilbackupseul,command: ["backup.sh"]; .gitignore,.dockerignore;BackupContainers,BackupImageVersionTest,BackupScriptsLintITetBackupIT;docs/sauvegardes.md(sections 1 à 5), la ligne du skillbackend-conventions.
Testable seule :
docker compose run --rm backup backup.sh, puisverify.sh latest. -
Restauration (
feat(sauvegardes): restauration sur base vide avec vérification) :restore.sh, service composerestore;RestoreIT;docs/sauvegardes.md(sections 6 à 9), lien depuis le README.
Testable seule : la procédure de restauration complète.
-
Planification et rotation (
feat(sauvegardes): planification quotidienne, rotation et état de santé) :scheduler.sh,prune.shethealthcheck.sh, liens physiques dansbackup.sh;- le service
backuppasse au profilappen plus debackup, avec la commande par défaut, lehealthchecketinit: true; - section « Sauvegardes » de
.env.example; BackupScheduleIT.
Testable seule :
docker compose --profile backup up -d backup, une sauvegarde immédiate, puishealthy. -
Test de restauration périodique (
feat(sauvegardes): test de restauration hebdomadaire dans une base jetable) :restore-test.sh, appel depuisscheduler.sh, champs destatus.json;- cas de
RestoreIT; - documentation ;
- case du backlog cochée avec le résumé et les éléments reportés.
Alternatives écartées
@Scheduleddans Spring :pg_dumpn’est pas dans l’image JRE, et l’y ajouter lierait la version du client à celle de l’application ;- l’application devrait lire tout son volume et écrire une destination montée en écriture ;
- surtout, la sauvegarde doit marcher quand l’application est arrêtée ou cassée (migration en échec, boucle de redémarrage), c’est-à-dire précisément quand on en a besoin ;
- un dump en Java (lectures par
COPY) réinventeraitpg_dumpsans les objets du schéma.
- cron de l’hôte qui appelle
docker compose exec db pg_dump: dépend de l’hôte (Windows en développement, NAS, VPS), n’est ni versionné ni testé avec le reste, et ne sauvegarde pas les fichiers dans le même mouvement. Il reste possible au déploiement de déclencherdocker compose run --rm backup backup.shdepuis un planificateur externe, sans changer les scripts. - BusyBox
cronddans le conteneur : l’environnement n’est pas transmis aux tâches (il faudrait l’écrire dans un fichier, mot de passe compris), une exécution manquée n’est pas rattrapée, et il est difficile à tester. La boucleest_duese teste parBACKUP_NOW. - Images toutes faites :
prodrigestivill/postgres-backup-localne sauvegarde que la base ;offen/docker-volume-backuparchive les volumes sans l’ordre « dump, puis fichiers » ni le verroufile.gc, et sauvegarderdb-dataà chaud donne une copie incohérente sans arrêter la base ;- aucune ne fournit la restauration vérifiée ni le test de bout en bout.
- restic ou borg dès maintenant (déduplication, chiffrement, destinations distantes) : excellents candidats pour la copie hors site du déploiement, en lisant
/backups. Ils imposent dès aujourd’hui une clé à conserver et un format opaque. Les instantanés en clair restent la source simple et vérifiable, que ces outils pourront reprendre sans changer cet ADR. - Sauvegarde physique (
pg_basebackupavec archivage WAL, restauration à un instant donné) : objectif de perte de données à la minute, au prix d’une configuration du serveur, d’un archivage continu et d’une restauration bien plus délicate. Une perte d’au plus 24 h est acceptable pour un usage personnel (voir Reporté). - Instantané du volume
db-data: incohérent à chaud, ou base arrêtée chaque nuit. Lié à la version et à l’architecture du serveur. - Archive
files.tarpar instantané : chaque instantané copie tous les contenus. Avec 20 Go de fichiers et 15 instantanés, cela fait 300 Go. Les liens physiques donnent le même résultat autonome pour le coût des seuls contenus nouveaux. - Magasin de contenus partagé entre instantanés (
/backups/blobs/ab/cd/<sha256>et une liste par instantané) : économe, mais il faudrait un ramasse-miettes des contenus, une opération dangereuse à écrire et à tester, et un instantané ne serait plus autonome. Avec les liens physiques, le système de fichiers fait ce travail. - Archives
.tar.gzpourconfig/ou les fichiers :config/est minuscule et plus utile en clair (diff -r). Les fichiers sont surtout des PDF et des images déjà compressés. - Manifeste couvrant aussi
files/: redondant avec les noms adressés par contenu, et volumineux (100 000 lignes par instantané). - Sauvegarder tout
/dataplutôt que/data/files: le raisonnement de cohérence (immuabilité, verroufile.gc) ne vaut que pour les contenus. Une donnée future sous/data(miniatures, index) devra dire elle-même si elle se sauvegarde ou se reconstruit. D’où la règle ajoutée au skillbackend-conventions. restore.sh --force(vider la base et le dossier avant de restaurer) : une seule faute de frappe effacerait les données courantes. Le refus sur une cible non vide, avec la commande à lancer dans le message, est plus sûr pour un geste rare.- Restaurer
config/par-dessus./config: écraserait des définitions plus récentes ou non commitées. Le dossier est en lecture seule pour l’application, et versionné. - Test de bout en bout par un script shell lancé en CI : le projet n’a pas de CI, et les tests d’intégration Testcontainers sont la convention (
CLAUDE.md). Le test Java crée en plus un jeu de données réaliste par les services, et compare avecFlyway.validate(). - Fusionner sauvegarde et export planifié : l’export (ADR 0018) sert la portabilité. Il est lisible, mais avec perte (ni corbeille ni historique) et sans restauration possible. La sauvegarde sert la reprise après sinistre : elle est complète et restaurable, mais son format n’est pas destiné à être lu. Les besoins et les tests diffèrent. L’export planifié reste reporté (ADR 0018).
Conséquences
- Perte maximale de 24 h, avec un rattrapage au redémarrage et une nouvelle tentative toutes les heures après un échec. Durée de restauration : quelques minutes par gigaoctet de fichiers (
rsync), plus le recalcul denode.searchet des index. - Sur le même hôte : tant que la copie hors site n’existe pas (déploiement), une panne de disque ou la perte du serveur emporte les données et les sauvegardes. Les sauvegardes du jalon 1 protègent des erreurs logicielles, des erreurs de manipulation et des mises à jour ratées, pas d’un sinistre.
- En clair :
/backupscontient tous les documents personnels, protégé seulement parumask 077(root). Sur un hôte Linux, les fichiers appartiennent à root : la copie hors site devra tourner en root, ou le déploiement devra fixer l’uid du conteneur. - Liens physiques : modifier un contenu dans un instantané le modifie dans tous ceux qui le partagent. Les instantanés sont à traiter en lecture seule, et
verify.shdétecte une altération. Une copie hors site doit préserver les liens (rsync -H, restic) ou accepter de multiplier l’espace. - Verrou
file.gcpartagé tenu pendant toute la plage : sans effet sur l’application (dépôts en mode partagé). La purge (jalon 3) attendra jusqu’à la fin d’une sauvegarde, plusieurs minutes pour une première copie volumineuse. - Version de PostgreSQL : l’image de
dbet leFROMdebackup/Dockerfiledoivent rester identiques (testBackupImageVersionTest). Un dump ne se restaure que dans un serveur de version supérieure ou égale (refus explicite sinon). - Restaurer un instantané plus ancien que l’application fonctionne : Flyway joue les migrations manquantes au démarrage. Restaurer un instantané plus récent que l’application échoue au démarrage (validation Flyway). Il faut redéployer la version correspondante, ce que documente
docs/sauvegardes.md. - Construction de l’image : le contexte de l’image de l’application n’embarque plus
data/,backups/niconfig.restauree/. - Rien n’est visible dans l’interface au jalon 1 : l’état se lit dans
docker compose ps,backups/status.jsonet les journaux. - Obligations reportées :
- purge (jalon 3) :
- prendre
file.gcen exclusif avecpg_try_advisory_xact_locket remettre la purge à plus tard si une sauvegarde le tient, au lieu de bloquer ; - supprimer les fichiers pendant qu’elle tient le verrou (avant le commit, ou sous un verrou de session), sinon une sauvegarde qui démarre juste après le commit pourrait lister des lignes déjà retirées du disque ;
- prendre
- toute nouvelle donnée persistante (dossier sous
/data, volume, base) : ajoutée àbackup.sh,restore.shet aux tests, par amendement de cet ADR ; - multi-emplacement et versions de fichiers (V2) : un contenu qui ne serait plus immuable ou plus sous
/data/filesremet en cause l’ordre « dump puis fichiers » et les liens physiques ; - page « Données » (ADR 0018) : elle pourra afficher
status.jsonsi le déploiement le monte en lecture seule dans le conteneurapp.
- purge (jalon 3) :
Reporté
À la tranche Déploiement (dépend de l’hébergement) :
- Copie hors site : par exemple restic ou rclone vers un stockage objet, ou un second disque, en lisant
/backups, avec sa propre rotation et sa vérification (restic check). - Chiffrement des sauvegardes hors site (restic,
age), et garde de la clé hors du serveur. - Alerte : brancher le
healthcheckoustatus.jsonsur une notification (healthchecks.io, Uptime Kuma, courriel). Aujourd’hui, l’échec est visible mais personne n’est prévenu. - Emplacement de
BACKUP_PATHsur un autre disque que les volumes, avec les droits et l’uid du dossier de l’hôte. - Lancement par le planificateur de l’hôte (systemd) si l’hébergement s’y prête, au lieu de la boucle.
Plus tard :
- Plusieurs sauvegardes par jour, ou archivage WAL et restauration à un instant donné.
- Contrôle d’espace libre avant la sauvegarde (aujourd’hui, un disque plein donne un échec explicite et laisse les instantanés précédents intacts).
- État des sauvegardes dans l’application (page « Données », Actuator ; Observabilité, V2).
- Restauration partielle (un nœud ou un fichier) : par l’import de l’export (V2), ou à la main depuis un instantané restauré dans la base jetable.
- Sauvegarde de
.env, sous forme chiffrée seulement.
Questions ouvertes tranchées (mode autonome)
- Lieu : service compose dédié (
backup/, basepostgres:17-alpine,rsyncetjqajoutés), en root, données en lecture seule,umask 077. Pas de@Scheduled. - Planification : boucle
scheduler.shtoutes les 5 min, créneau quotidienBACKUP_TIME=03:00dansTZ=Europe/Brussels, sauvegarde immédiate s’il n’y en a jamais eu, rattrapage, nouvelle tentative après 60 min. Pas de cron. - Activation : profils
appetbackup.docker compose up -d db(développement) ne la lance pas. Source des fichiers parBACKUP_DATA_SOURCE(app-dataou./data). - Destination :
./backupssur l’hôte (BACKUP_PATH), pas un volume nommé. - Format : dossier horodaté UTC
AAAAMMJJTHHMMSSZqui contientdb.dump(-Fc),files/(même disposition, sans.tmp/, liens physiques avec le précédent si possible, copie sinon),config/en clair,blobs.tsv,backup.json,SHA256SUMS(sansfiles/, dont les noms sont les empreintes). Écrit en.partial, puis renommé. - Cohérence : flock (une exécution à la fois), puis
file.gcpartagé sur une sessionpsqldédiée tenue par une FIFO,lock_timeoutde 600 s, puis dump, puis listefile_blob, puis copie, puis contrôle des tailles. Un SHA-256 faux de l’échantillon est un échec. Un contenu manquant à la source donnewarning. - Rotation : 7 quotidiennes, 4 hebdomadaires, 6 mensuelles, configurables. Clés UTC, le plus récent de chaque période. Le plus récent est toujours gardé, ainsi que tout instantané marqué
KEEP. Rotation seulement après réussite. - Restauration :
restore.sh <nom|latest>sur une base et un dossier vides, refus sinon, aucune option destructive. Vérification préalable,pg_restore --single-transaction --exit-on-error --no-owner --no-privileges,analyze,rsyncdes fichiers,chown 10001:10001(configurable), contrôle après restauration.config/va dans./config.restauree, jamais par-dessus. - Vérification : tailles de tous les contenus, SHA-256 d’un échantillon de 20 (
allpossible), manifeste etpg_restore --list. - Restauration testée :
- tests d’intégration Testcontainers qui exécutent les vrais scripts et comparent par empreinte chaque table, séquence, extension et fichier, plus
Flyway.validate(); - test hebdomadaire automatique dans
<base>_restore_test; - exercice manuel trimestriel documenté.
- tests d’intégration Testcontainers qui exécutent les vrais scripts et comparent par empreinte chaque table, séquence, extension et fichier, plus
- Échec visible : codes 0, 1 et 2,
status.json(une ligne,jq),last-error.log,healthcheckcompose (non sain sifailed,warning, réussite de plus de 26 h, ou test de restauration en échec). - Windows : scripts exécutés dans le conteneur, LF forcé (
.gitattributesetseddans le Dockerfile), arguments par nom d’instantané (pas de chemin réécrit par Git Bash), tests par copie dans le conteneur et non par montage. - Hors périmètre, reporté au déploiement : copie hors site, chiffrement, alerte, emplacement sur un autre disque.
Écarts de mise en œuvre
Constatés à l’implémentation (2026-10-15) ; le reste de l’ADR est appliqué tel quel.
set -euau lieu deset -eu -o pipefail: ShellCheck en-s shsignalepipefail, et aucun script n’en dépend (les résultats de commandes en pipeline sont écrits dans des fichiers).status.jsongagnelastRestoreTestEpoch(le planificateur compare des secondes epoch, pas des dates ISO).- Trois directives ShellCheck documentées dans les scripts : SC2016 (les
$xdes filtres jq), SC2317 (fonctions appelées partrap), SC2034 (FAIL_MESSAGElu par le piège de sortie). backup.shvalide aussi le groupe « planification » (BACKUP_TIME=25:00fait échouer une sauvegarde).- Le cas de test «
/data/filesabsent,blobs.tsvvide » utilise une seconde base migrée et sans contenu : la base du jeu de données contient desfile_blob. - Le service
backupest livré dans sa forme finale (tranche 3) et non de façon incrémentale. RESTORE_FILES_OWNERvaut10001(uid seul, groupe inchangé) et non10001:10001.restore-test.shécritstatus.jsonseulement après les contrôles du nom de base (un refus n’est pas un résultat de test) ; la vérification de l’instantané y est faite après la création de la base jetable.- Ajoutés après revue :
- la rotation n’est lancée que si l’état est
ok(pas après unwarning) ; restore-test.shetprune.shprennent le verrou/backups/.lock(flock -n), sauf quandbackup.shappelleprune.sh(SB_LOCK_HELD=1) ;prune.sh --dry-runn’en a pas besoin ;- un test de restauration en échec est retenté dès la sauvegarde réussie suivante ;
missing-files.tsvest couvert parSHA256SUMSquand il existe, etverify.shexigedb.dump,blobs.tsvetbackup.jsondans le manifeste ;- une écriture de
SHA256SUMSen échec fait échouer la sauvegarde ; BACKUP_LOCK_TIMEOUT,BACKUP_RETRY_MINUTESetBACKUP_MAX_AGE_HOURSsont transmis par compose ;- crochet de test
BACKUP_TEST_PAUSE_AFTER_COPY(pause après la copie des fichiers, verrou tenu) ; trap '' PIPElimité à l’écriture dans la FIFO.
- la rotation n’est lancée que si l’état est