Second Brain documentation

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)

← Toutes les décisions

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 depuis backup/Dockerfile sur postgres:17-alpine (même version majeure que le serveur), avec rsync et jq. Il contient des scripts POSIX sh et 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/files sans .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>.partial puis renommé : un instantané incomplet ne passe jamais pour complet.

  • Cohérence : verrou consultatif file.gc partagé, tenu pendant toute la plage. Ensuite pg_dump d’abord, puis la liste file_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 fichier KEEP protè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é, lance pg_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.
  • Échec visible :

    • codes de sortie 0 (réussite), 1 (échec) et 2 (instantané complet avec anomalies) ;
    • /backups/status.json, sur une ligne, lu par le healthcheck du 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, volume db-data, superutilisateur secondbrain, mot de passe ${DB_PASSWORD}) ;
  • service app (profil app) : ./config:/config:ro et app-data:/data, fichiers dans /data/files. L’image JRE tourne sous l’utilisateur app (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/files relatif à backend/), pas dans le volume app-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_dump et pg_restore 17.x, tzdata, flock, mkfifo, shuf, sha256sum et date -d sont présents (vérifié). L’image est déjà téléchargée pour db et pour les tests. Seuls rsync (copie avec --link-dest) et jq (JSON écrit et lu sans échappement fait main) sont ajoutés.
  • Scripts POSIX sh (ash de BusyBox) avec set -eu -o pipefail : pas de bash. Ils tournent toujours dans le conteneur, jamais sous Windows. .gitattributes force déjà eol=lf, et le sed du Dockerfile protège d’un dépôt cloné autrement.
  • Le conteneur tourne en root : il doit lire /data quel que soit le propriétaire des fichiers, écrire dans /backups et, à la restauration, rendre les fichiers à l’uid de l’application. Ses montages de données sont en lecture seule pour la sauvegarde. Les scripts posent umask 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 :
    • backup fait partie du profil app, donc docker compose --profile app up -d lance aussi les sauvegardes ;
    • docker compose up -d db, la commande habituelle en développement, ne le lance pas ;
    • restore n’est démarré que par docker compose run --rm restore …, qui active son profil implicitement.
  • Destination :
    • par défaut ./backups à côté du docker-compose.yml, un montage de l’hôte : visible, copiable, et l’endroit naturel de la future copie hors site ;
    • BACKUP_PATH la 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.
  • Source des fichiers : BACKUP_DATA_SOURCE vaut app-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ême build:, 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 postgres déclare VOLUME /var/lib/postgresql/data. Le service backup garde donc un volume anonyme vide. Il est sans effet et supprimé par run --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 par rm -rf sans toucher aux autres ;
    • au début de chaque exécution, backup.sh teste ln dans /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/files n’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 par diff -r.

  • blobs.tsv : la liste file_blob lue 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 : format sha256sum, chemins relatifs. Il couvre db.dump, blobs.tsv, backup.json et chaque fichier de config/. On le contrôle par sha256sum -c SHA256SUMS.

  • backup.json (informatif, écrit par jq) :

    {
      "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
    }

    schemaVersion est la plus haute version réussie de flyway_schema_history.

backup.sh : ordre des opérations

backup.sh            une sauvegarde complète puis la rotation ; code 0, 1 ou 2
  1. 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.json n’est pas modifié, puisque l’autre exécution l’écrit.

  2. status.json passe à running (lastAttemptAt). Les dossiers *.partial laissés par une exécution interrompue sont supprimés.

  3. Création de <nom>.partial/.

  4. Verrou file.gc partagé, tenu sur une session dédiée. Un psql en 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) : échec

    La fin du script, normale ou brutale, ferme le descripteur 3. psql lit alors la fin de fichier et se termine, et la session libère le verrou. Le signal passe par \! touch et non par la sortie de psql, mise en mémoire tampon. lock_timeout s’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.

  5. pg_dump --format=custom --file=db.dump. Le dump lit un instantané cohérent de toute la base, node_event et corbeille compris.

  6. blobs.tsv : copy (select sha256, size from file_blob order by sha256) to stdout, lu après le dump.

  7. Copie des fichiers : rsync -a --exclude=/.tmp/ [--link-dest=<instantané précédent>/files] /data/files/ <partial>/files/.

  8. Libération du verrou file.gc (fermeture du descripteur 3).

  9. Copie de config/ (cp -a /config/. <partial>/config/). Un dossier /config absent est un échec : le montage est mal configuré.

  10. Contrôles :

    • chaque ligne de blobs.tsv a son fichier dans <partial>/files/ avec la bonne taille. Les manquants vont dans missing-files.tsv, et l’état devient warning ;
    • pg_restore --list db.dump : la table des matières du dump est lisible ;
    • SHA-256 de BACKUP_VERIFY_SAMPLE contenus tirés au hasard (shuf), égal à leur nom.

    Un SHA-256 faux est un échec : la copie est abîmée.

  11. backup.json, puis SHA256SUMS.

  12. mv <nom>.partial <nom>, un renommage atomique dans le même dossier.

  13. status.json passe à ok ou warning.

  14. Rotation (prune.sh), seulement si l’instantané est complet. Une erreur de rotation est journalisée et fait passer l’état à warning sans supprimer l’instantané.

  15. Code de sortie : 0 (ok), 2 (warning).

En cas d’erreur (piège EXIT) :

  • <nom>.partial est supprimé ;
  • les 200 dernières lignes de la sortie d’erreur vont dans last-error.log ;
  • status.json passe à failed avec 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.tsv lu 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}
  • state vaut running, ok, warning ou failed. lastSuccess* reste celui de la dernière exécution ok ou warning.

  • healthcheck.sh renvoie 0 si et seulement si :

    • status.json existe ;
    • state vaut ok ou running ;
    • lastSuccessEpoch date de moins de BACKUP_MAX_AGE_HOURS ;
    • lastRestoreTestState ne vaut pas failed.

    Sinon il renvoie 1 et affiche la raison. warning est donc « non sain » : un contenu manquant à la source est une perte de données à traiter, pas un détail. docker compose ps affiche unhealthy. 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 healthcheck a 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-due affiche due ou not-due et sort. Avec BACKUP_NOW, c’est le point d’entrée des tests.
  • init: true (tini) : docker compose stop interrompt proprement. Un instantané en cours reste en .partial et 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_DAILY derniers jours distincts (clé %Y%m%d, en UTC) ;
  • hebdomadaire : le plus récent de chacune des BACKUP_KEEP_WEEKLY dernières semaines ISO distinctes (%G-W%V) ;
  • mensuel : le plus récent de chacun des BACKUP_KEEP_MONTHLY derniers 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
  1. Le nom désigne un instantané complet (pas de .partial).
  2. sha256sum -c SHA256SUMS passe.
  3. pg_restore --list db.dump passe.
  4. Chaque ligne de blobs.tsv absente de missing-files.tsv a son fichier avec la bonne taille.
  5. 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.

  1. verify.sh de l’instantané. Un échec arrête tout, et rien n’a été écrit.

  2. Base cible vide : aucune relation hors pg_catalog, information_schema et schémas pg_toast*/pg_temp*, et aucune extension autre que plpgsql. Sinon : « La base n’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.

  3. Dossier de fichiers cible vide : RESTORE_FILES_DIR absent, ou vide en dehors d’un .tmp/ vide. Sinon, refus.

  4. 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).

  5. pg_restore --single-transaction --exit-on-error --no-owner --no-privileges -d "$PGDATABASE" db.dump, puis analyze. Avec une seule transaction, un échec laisse la base vide et le script peut être relancé. La colonne générée node.search est recalculée à la restauration.

  6. 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, si RESTORE_FILES_OWNER n’est pas vide, chown -R de RESTORE_FILES_DIR et de son parent immédiat : le volume app-data recréé par ce conteneur appartiendrait sinon à root, et l’application (uid 10001) ne pourrait plus y écrire.

  7. Contrôle après restauration, sur la base restaurée :

    • chaque ligne de file_blob a son fichier avec la bonne taille ;
    • le SHA-256 de l’échantillon est égal au nom ;
    • on affiche les compteurs (node hors corbeille, en corbeille, file_blob, node_event) et schemaVersion.

    Une anomalie fait échouer le script. Base et fichiers restent alors en place pour examen, et le message indique comment repartir de zéro.

  8. config/ : copiée dans RESTORE_CONFIG_DIR (./config.restauree sur 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).

  9. 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 :

  1. 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).
  2. Activer :
    • image complète : docker compose --profile app up -d ;
    • développement : BACKUP_DATA_SOURCE=./data docker compose --profile backup up -d backup.
  3. Sauvegarde manuelle : docker compose run --rm backup backup.sh. Protéger un instantané : touch backups/<nom>/KEEP.
  4. Surveiller :
    • docker compose ps (healthy/unhealthy) ;
    • backups/status.json ;
    • docker compose logs backup.
  5. Vérifier un instantané : docker compose run --rm backup verify.sh latest --sample all.
  6. Restaurer (image complète) :
    1. si possible, sauvegarder l’état actuel (docker compose run --rm backup backup.sh) ;
    2. docker compose --profile app down (les volumes sont conservés) ;
    3. docker volume rm <projet>_db-data <projet>_app-data (destructif, nom du projet donné par docker volume ls) ;
    4. docker compose up -d db (base neuve et vide) ;
    5. docker compose run --rm restore latest (ou <nom>) ;
    6. comparer ./config.restauree et ./config, remplacer si besoin ;
    7. docker compose --profile app up -d, puis vérifier dans l’interface.
  7. Restaurer en développement :
    • même chose avec BACKUP_DATA_SOURCE=./data, après avoir vidé ./data/files et le volume db-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).
  8. 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.
  9. Monter de version PostgreSQL : changer en même temps l’image de db et le FROM de backup/Dockerfile (test BackupImageVersionTest). 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, puis createdb, puis pg_restore --single-transaction --exit-on-error --no-owner --no-privileges.
    • Il contrôle ensuite les lignes de file_blob contre <instantané>/files (tailles de toutes les lignes, SHA-256 de l’échantillon), puis lance dropdb, y compris en cas d’échec (piège).
  • Appelé par scheduler.sh après une sauvegarde ok, si le dernier test date de plus de BACKUP_RESTORE_TEST_DAYS jours. Il écrit lastRestoreTestAt et lastRestoreTestState (ok/failed) dans status.json. Un échec rend le healthcheck non 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 : secondbrain est 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.md et skills inchangés. Le skill backend-conventions gagne 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) ».
  • .gitignore gagne /backups/ et /config.restauree/. .dockerignore gagne backups, config.restauree et data : 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 pas data/.

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 de db dans docker-compose.yml et le FROM de backup/Dockerfile sont identiques.

  • BackupScriptsLintIT : koalaman/shellcheck-alpine (version épinglée) en mode -s sh sur backup/bin/*.sh, sans avertissement. Il garde les scripts POSIX pour l’ash de BusyBox.

  • BackupIT (@SpringBootTest, profil dev, PostgreSQL sur un Network avec l’alias db, app.files.path dans 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_event variés ;
    • un fichier abandonné .tmp/orphelin.

    Les fichiers et ../config sont 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 SHA256SUMS et pg_restore --list passent ;
      • blobs.tsv est égal à file_blob ;
      • fileDigests(files/) est égal à la source sans .tmp/ ;
      • config/ est identique à ../config ;
      • backup.json a ses compteurs et schemaVersion ;
    • /data/files absent : code 0, files/ vide, blobs.tsv vide ;
    • contenu manquant à la source (fichier supprimé avant la sauvegarde) : code 2, état warning, missing-files.tsv contient son SHA-256, l’instantané est complet ;
    • base injoignable (PGHOST=inconnu) : code 1, état failed, last-error.log présent, aucun nouvel instantané, aucun .partial, les instantanés précédents sont intacts ;
    • verrou file.gc tenu en exclusif par une connexion JDBC du test (pg_advisory_lock) avec BACKUP_LOCK_TIMEOUT=2 : code 1 en moins de 10 s, message qui cite file.gc. Après libération, une nouvelle exécution réussit ;
    • dépôt pendant la sauvegarde : backup.sh est lancé dans un thread du test avec BACKUP_TEST_PAUSE_AFTER_DUMP=5. Pendant la pause, verrou partagé tenu :
      • pg_try_advisory_lock(hashtext('file.gc')) (exclusif) renvoie false depuis une connexion du test ;
      • un dépôt par NodeFileService ré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, puis backup.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 de db.dump ; 1 après suppression d’un contenu de blobs.tsv dans files/.

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, puis restore.sh latest avec PGHOST=db-restore : code 0 ;
    • tableDigests(source) == tableDigests(restaurée), tables, séquences et extensions comprises (node.search recalculé é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/config est identique à ../config ;
  • base non vide : un second restore.sh latest donne 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/files n’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, avec BACKUP_NOW, TZ=Europe/Brussels et un status.json pré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.
  • prune.sh sur des dossiers vides créés par mkdir (60 instantanés quotidiens sur deux mois, des doublons dans la journée, un .partial, un KEEP ancien, 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-run ne supprime rien. Le dossier au nom invalide n’est jamais touché.
  • Liens physiques : deux backup.sh successifs, 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 avec ok récent ; 1 avec failed, avec warning, avec un ok plus vieux que BACKUP_MAX_AGE_HOURS (via BACKUP_NOW), sans status.json, et avec lastRestoreTestState = failed.
  • Configuration invalide (BACKUP_KEEP_DAILY=0, BACKUP_TIME=25:00) : code 1, état failed, 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

  1. Sauvegarde manuelle (feat(sauvegardes): image et script de sauvegarde (dump, fichiers, config, manifeste)) :

    • backup/Dockerfile, common.sh, backup.sh (sans --link-dest ni rotation) et verify.sh ;
    • service compose backup, profil backup seul, command: ["backup.sh"] ;
    • .gitignore, .dockerignore ;
    • BackupContainers, BackupImageVersionTest, BackupScriptsLintIT et BackupIT ;
    • docs/sauvegardes.md (sections 1 à 5), la ligne du skill backend-conventions.

    Testable seule : docker compose run --rm backup backup.sh, puis verify.sh latest.

  2. Restauration (feat(sauvegardes): restauration sur base vide avec vérification) :

    • restore.sh, service compose restore ;
    • RestoreIT ;
    • docs/sauvegardes.md (sections 6 à 9), lien depuis le README.

    Testable seule : la procédure de restauration complète.

  3. Planification et rotation (feat(sauvegardes): planification quotidienne, rotation et état de santé) :

    • scheduler.sh, prune.sh et healthcheck.sh, liens physiques dans backup.sh ;
    • le service backup passe au profil app en plus de backup, avec la commande par défaut, le healthcheck et init: true ;
    • section « Sauvegardes » de .env.example ;
    • BackupScheduleIT.

    Testable seule : docker compose --profile backup up -d backup, une sauvegarde immédiate, puis healthy.

  4. Test de restauration périodique (feat(sauvegardes): test de restauration hebdomadaire dans une base jetable) :

    • restore-test.sh, appel depuis scheduler.sh, champs de status.json ;
    • cas de RestoreIT ;
    • documentation ;
    • case du backlog cochée avec le résumé et les éléments reportés.

Alternatives écartées

  • @Scheduled dans Spring :
    • pg_dump n’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éinventerait pg_dump sans 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éclencher docker compose run --rm backup backup.sh depuis un planificateur externe, sans changer les scripts.
  • BusyBox crond dans 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 boucle est_due se teste par BACKUP_NOW.
  • Images toutes faites :
    • prodrigestivill/postgres-backup-local ne sauvegarde que la base ;
    • offen/docker-volume-backup archive les volumes sans l’ordre « dump, puis fichiers » ni le verrou file.gc, et sauvegarder db-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_basebackup avec 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.tar par 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.gz pour config/ 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 /data plutôt que /data/files : le raisonnement de cohérence (immuabilité, verrou file.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 skill backend-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 avec Flyway.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 de node.search et 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 : /backups contient tous les documents personnels, protégé seulement par umask 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.sh détecte une altération. Une copie hors site doit préserver les liens (rsync -H, restic) ou accepter de multiplier l’espace.
  • Verrou file.gc partagé 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 db et le FROM de backup/Dockerfile doivent rester identiques (test BackupImageVersionTest). 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/ ni config.restauree/.
  • Rien n’est visible dans l’interface au jalon 1 : l’état se lit dans docker compose ps, backups/status.json et les journaux.
  • Obligations reportées :
    • purge (jalon 3) :
      • prendre file.gc en exclusif avec pg_try_advisory_xact_lock et 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 ;
    • toute nouvelle donnée persistante (dossier sous /data, volume, base) : ajoutée à backup.sh, restore.sh et 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/files remet en cause l’ordre « dump puis fichiers » et les liens physiques ;
    • page « Données » (ADR 0018) : elle pourra afficher status.json si le déploiement le monte en lecture seule dans le conteneur app.

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 healthcheck ou status.json sur une notification (healthchecks.io, Uptime Kuma, courriel). Aujourd’hui, l’échec est visible mais personne n’est prévenu.
  • Emplacement de BACKUP_PATH sur 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/, base postgres:17-alpine, rsync et jq ajoutés), en root, données en lecture seule, umask 077. Pas de @Scheduled.
  • Planification : boucle scheduler.sh toutes les 5 min, créneau quotidien BACKUP_TIME=03:00 dans TZ=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 app et backup. docker compose up -d db (développement) ne la lance pas. Source des fichiers par BACKUP_DATA_SOURCE (app-data ou ./data).
  • Destination : ./backups sur l’hôte (BACKUP_PATH), pas un volume nommé.
  • Format : dossier horodaté UTC AAAAMMJJTHHMMSSZ qui contient db.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 (sans files/, dont les noms sont les empreintes). Écrit en .partial, puis renommé.
  • Cohérence : flock (une exécution à la fois), puis file.gc partagé sur une session psql dédiée tenue par une FIFO, lock_timeout de 600 s, puis dump, puis liste file_blob, puis copie, puis contrôle des tailles. Un SHA-256 faux de l’échantillon est un échec. Un contenu manquant à la source donne warning.
  • 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, rsync des 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 (all possible), manifeste et pg_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é.
  • Échec visible : codes 0, 1 et 2, status.json (une ligne, jq), last-error.log, healthcheck compose (non sain si failed, warning, réussite de plus de 26 h, ou test de restauration en échec).
  • Windows : scripts exécutés dans le conteneur, LF forcé (.gitattributes et sed dans 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 -eu au lieu de set -eu -o pipefail : ShellCheck en -s sh signale pipefail, et aucun script n’en dépend (les résultats de commandes en pipeline sont écrits dans des fichiers).
  • status.json gagne lastRestoreTestEpoch (le planificateur compare des secondes epoch, pas des dates ISO).
  • Trois directives ShellCheck documentées dans les scripts : SC2016 (les $x des filtres jq), SC2317 (fonctions appelées par trap), SC2034 (FAIL_MESSAGE lu par le piège de sortie).
  • backup.sh valide aussi le groupe « planification » (BACKUP_TIME=25:00 fait échouer une sauvegarde).
  • Le cas de test « /data/files absent, blobs.tsv vide » utilise une seconde base migrée et sans contenu : la base du jeu de données contient des file_blob.
  • Le service backup est livré dans sa forme finale (tranche 3) et non de façon incrémentale.
  • RESTORE_FILES_OWNER vaut 10001 (uid seul, groupe inchangé) et non 10001:10001.
  • restore-test.sh écrit status.json seulement 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 un warning) ;
    • restore-test.sh et prune.sh prennent le verrou /backups/.lock (flock -n), sauf quand backup.sh appelle prune.sh (SB_LOCK_HELD=1) ; prune.sh --dry-run n’en a pas besoin ;
    • un test de restauration en échec est retenté dès la sauvegarde réussie suivante ;
    • missing-files.tsv est couvert par SHA256SUMS quand il existe, et verify.sh exige db.dump, blobs.tsv et backup.json dans le manifeste ;
    • une écriture de SHA256SUMS en échec fait échouer la sauvegarde ;
    • BACKUP_LOCK_TIMEOUT, BACKUP_RETRY_MINUTES et BACKUP_MAX_AGE_HOURS sont transmis par compose ;
    • crochet de test BACKUP_TEST_PAUSE_AFTER_COPY (pause après la copie des fichiers, verrou tenu) ;
    • trap '' PIPE limité à l’écriture dans la FIFO.