From c9580fd0a8287859d4ec65abc234b25d54701c2a Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Mon, 7 Sep 2026 16:11:33 +0200 Subject: [PATCH] Scripts de sauvegarde Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dump -Fc avec vérification de relecture et rotation, test de restauration qui compare les comptages table par table, extraction par instance, contrôle de fraîcheur, units systemd et procédure de restauration écrite. Le dump Mongo est volontairement en lecture seule : aucun mongorestore, pour qu'aucune erreur de manipulation ne puisse écrire dans la prod. Ce qui manque pour que ce soit une sauvegarde : BACKUP_DEST n'est pas tranché. Tant qu'il est vide, le dump reste sur la machine qui héberge la base et ne protège de rien — le script le dit à chaque exécution. Et notify.sh ne fait qu'un logger, donc les trois surveillances écrivent dans un journal que personne ne lit. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + ManagerService/Deployment/backup/README.md | 129 ++++++++++++++++++ ManagerService/Deployment/backup/RESTORE.md | 99 ++++++++++++++ .../Deployment/backup/backup-postgres.sh | 53 +++++++ .../Deployment/backup/check-freshness.sh | 40 ++++++ .../Deployment/backup/dump-mongo-prod.sh | 38 ++++++ .../Deployment/backup/extract-instance.sh | 87 ++++++++++++ ManagerService/Deployment/backup/notify.sh | 16 +++ .../Deployment/backup/restore-check.sh | 29 ++++ .../systemd/myim-backup-failure.service | 7 + .../systemd/myim-backup-freshness.service | 8 ++ .../systemd/myim-backup-freshness.timer | 9 ++ .../backup/systemd/myim-backup.env.example | 11 ++ .../backup/systemd/myim-backup.service | 10 ++ .../backup/systemd/myim-backup.timer | 11 ++ .../backup/systemd/myim-restore-check.service | 11 ++ .../backup/systemd/myim-restore-check.timer | 9 ++ 17 files changed, 570 insertions(+) create mode 100644 ManagerService/Deployment/backup/README.md create mode 100644 ManagerService/Deployment/backup/RESTORE.md create mode 100644 ManagerService/Deployment/backup/backup-postgres.sh create mode 100644 ManagerService/Deployment/backup/check-freshness.sh create mode 100644 ManagerService/Deployment/backup/dump-mongo-prod.sh create mode 100644 ManagerService/Deployment/backup/extract-instance.sh create mode 100644 ManagerService/Deployment/backup/notify.sh create mode 100644 ManagerService/Deployment/backup/restore-check.sh create mode 100644 ManagerService/Deployment/backup/systemd/myim-backup-failure.service create mode 100644 ManagerService/Deployment/backup/systemd/myim-backup-freshness.service create mode 100644 ManagerService/Deployment/backup/systemd/myim-backup-freshness.timer create mode 100644 ManagerService/Deployment/backup/systemd/myim-backup.env.example create mode 100644 ManagerService/Deployment/backup/systemd/myim-backup.service create mode 100644 ManagerService/Deployment/backup/systemd/myim-backup.timer create mode 100644 ManagerService/Deployment/backup/systemd/myim-restore-check.service create mode 100644 ManagerService/Deployment/backup/systemd/myim-restore-check.timer diff --git a/.gitignore b/.gitignore index 8a3e9d9..c276d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ migration-data/ # Dumps PostgreSQL (données clients — ne jamais committer) *.dump *.sql.gz + +# Sauvegardes locales — ne jamais committer de dump de données clients +ManagerService/Deployment/backups/ diff --git a/ManagerService/Deployment/backup/README.md b/ManagerService/Deployment/backup/README.md new file mode 100644 index 0000000..e14d298 --- /dev/null +++ b/ManagerService/Deployment/backup/README.md @@ -0,0 +1,129 @@ +# Sauvegardes — installation + +Complète le snapshot quotidien OVH, qui restaure un **disque** mais ne garantit pas un +Postgres cohérent. `pg_dump` prend un snapshot MVCC : c'est une sauvegarde au niveau base. + +Pour restaurer, voir **[RESTORE.md](RESTORE.md)**. + +## Scripts + +| Script | Rôle | +|---|---| +| `backup-postgres.sh` | dump `-Fc`, vérifie qu'il est relisible, rotation 7 jours, copie hors site | +| `restore-check.sh` | restaure dans une base jetable et compare les comptages — le seul test qui vaille | +| `extract-instance.sh` | extrait une seule instance d'un dump global (RGPD, récupération d'un client) | +| `check-freshness.sh` | alerte si la dernière sauvegarde a plus de 48 h | +| `dump-mongo-prod.sh` | sauvegarde de la prod Mongo, lecture seule, jusqu'à la bascule | +| `notify.sh` | point unique de notification, **à brancher sur un canal réel** | + +## Installation sur le VPS + +```sh +install -d /opt/myinfomate /var/backups/myinfomate +cp -r backup /opt/myinfomate/ +install -m 600 backup/systemd/myim-backup.env.example /etc/myim-backup.env +# renseigner BACKUP_DEST et BACKUP_ALERT_WEBHOOK +cp backup/systemd/*.service backup/systemd/*.timer /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now myim-backup.timer myim-backup-freshness.timer myim-restore-check.timer +systemctl start myim-backup.service && journalctl -u myim-backup.service -n 30 +``` + +`rclone` est requis dès que `BACKUP_DEST` est un remote (`gcs:`, `s3:`). + +## Pourquoi systemd et pas cron + +cron échoue en silence. `OnFailure=` appelle `notify.sh` sur tout échec, et les logs vont +dans journald. `Persistent=true` rattrape le tir si la machine était éteinte à l'heure prévue. + +## Pourquoi pas Hangfire + +Huit jobs récurrents existent déjà dans l'application ([Startup.cs](../../Startup.cs)). Mais une +sauvegarde de base pilotée par l'app qui dépend de cette base ne tourne pas quand l'app est +tombée — c'est-à-dire le jour où on en a besoin. Le timer reste indépendant du conteneur. + +## Les trois modes de panne, et ce qui les couvre + +| Panne | Couverture | +|---|---| +| Le script échoue | `OnFailure=` → `notify.sh` | +| Le script ne tourne plus depuis des semaines | `myim-backup-freshness.timer` | +| Les dumps existent mais ne se restaurent pas | `myim-restore-check.timer`, mensuel | + +Le deuxième est le mode réel, et c'est le seul qu'un `OnFailure` n'attrape pas. + +## Décisions + +**`ContentEmbeddings` exclu** (données seulement, le schéma reste) : régénérable depuis le +contenu CMS, et mesuré à 12 Mo → 1,7 Mo sur la base de dev. Contrepartie : réindexer après +restauration, étape 4 de RESTORE.md. + +**Rétention en deux étages** : 7 quotidiens sur le VPS, hebdo/mensuels par lifecycle rule côté +bucket. Le VPS ne garde pas son propre historique, puisque c'est lui qu'on suppose perdu. + +**Un dump global, pas un par client.** Une restauration par client se fait par extraction +depuis le dump global (`extract-instance.sh`). Des sauvegardes par client multiplieraient par +N les jobs à planifier, les échecs à surveiller et les restaurations à tester, pour la même +capacité de récupération. + +## La destination — arrêtée le 2026-09-07 + +| | | +|---|---| +| Projet GCP | `myinfomate-backups` (séparé de `mymuseum-3b97f`, qui porte la prod) | +| Bucket | `gs://unov-myinfomate-backups`, `EU` multi-région, versioning activé, accès public interdit | +| Préfixes | `pg/` pour les dumps Postgres, `media/` pour les médias | +| Identité | service account `backup-writer`, `objectCreator` + `objectViewer` | +| Rétention | lifecycle rule : suppression à 400 jours **sous `pg/` uniquement**, versions périmées à 30 jours | + +**Le service account ne peut pas supprimer, et c'est vérifié** — pas seulement voulu. Test du +2026-09-07 : l'écriture passe, le `rm` renvoie `HTTPError 403 storage.objects.delete denied`. +À rejouer si les rôles changent : + +```sh +export CLOUDSDK_CONFIG=$(mktemp -d) # config isolée, ne touche pas au compte courant +gcloud auth activate-service-account --key-file=backup-writer.json +gcloud storage cp t.txt gs://unov-myinfomate-backups/pg/t.txt # doit réussir +gcloud storage rm gs://unov-myinfomate-backups/pg/t.txt # doit échouer en 403 +``` + +Deux conséquences voulues de cette absence de droit de suppression : + +- la rotation longue **ne peut pas** venir du VPS : seule la lifecycle rule efface, côté Google, + hors d'atteinte d'une machine compromise ; +- la copie des médias ne pourra être qu'**additive** (`rclone copy`, jamais `sync`). Un fichier + effacé côté Firebase reste dans la sauvegarde — le bon comportement pour un backup, garanti + par les permissions et non par la discipline. + +Bonus non demandé : GCS active une `soft_delete_policy` de 7 jours par défaut sur les nouveaux +buckets. Seconde couche sous la précédente. + +## Ce qui manque encore + +- **`rclone` n'est pas installé ni configuré sur le VPS** — c'est la dernière pièce entre les + scripts et une sauvegarde réelle. +- **`notify.sh` n'est branché sur rien** — sans canal réel, les trois surveillances écrivent + dans le journal et personne ne les lit. +- **La copie des médias a été faite une fois, le 2026-09-07 — mais elle n'est pas planifiée.** + 1,43 Go, 2407 objets, serveur à serveur, 170 Mio/s : + ```sh + gcloud storage rsync -r gs://mymuseum-3b97f.appspot.com gs://unov-myinfomate-backups/media/ + ``` + Vérifiée : **taille identique à l'octet** des deux côtés. Le seul objet non copié est + `pictures/65ccc67265373befd15be511/`, un marqueur de dossier de 0 octet créé par la console + Firebase — `rsync` l'ignore à juste titre. + ⚠️ Toujours `rsync` **sans** `--delete-unmatched-destination-objects` : un fichier effacé côté + Firebase doit rester dans la sauvegarde. + + **Comment la planifier — décision du 2026-09-07.** Storage Transfer Service a été envisagé puis + écarté : il aurait fallu 5 attributions de rôles IAM et un agent de service à qui Google + recommande le droit de suppression, pour automatiser une commande à relancer toutes les + quelques semaines sur des données qui bougent de **~2 ressources par mois** (mesuré : 9 créées + en 5 mois). Disproportionné. Le mécanisme reste ce `rsync`, qui tourne avec un compte humain et + ne demande **aucun changement de permission**. Il sera ajouté aux timers du VPS en même temps + que la sauvegarde Postgres — un seul endroit, une seule mécanique. +- ✅ ~~Le bucket des médias n'a pas le versioning~~ → **activé le 2026-09-07**, avec purge des + versions non courantes à 30 jours. Le filet est donc en place **avant** que + `Firebase:StorageBucket` soit renseignée — jour où `Delete` commencera réellement à supprimer + les blobs. Aujourd'hui la clé est vide : supprimer une ressource dans manager-app retire la + ligne en base et **laisse le fichier**, ce qui est la source des orphelins. diff --git a/ManagerService/Deployment/backup/RESTORE.md b/ManagerService/Deployment/backup/RESTORE.md new file mode 100644 index 0000000..93a8e45 --- /dev/null +++ b/ManagerService/Deployment/backup/RESTORE.md @@ -0,0 +1,99 @@ +# Restauration — procédure + +> Document à lire **sous stress**. Il ne cherche pas à expliquer, il dit quoi taper. +> La procédure est rejouée automatiquement chaque mois par `myim-restore-check.timer`. + +**Règle unique, jamais enfreinte : la base de production n'est jamais la cible d'un `pg_restore`.** +On restaure toujours dans une base neuve, on regarde, puis on décide. + +--- + +## Scénario A — sinistre complet (la machine est perdue) + +1. Récupérer le dump depuis la destination hors site : + ```sh + rclone lsl "$BACKUP_DEST" | sort -k2 | tail -5 # choisir, ne pas prendre le dernier à l'aveugle + rclone copy "$BACKUP_DEST/.dump" . + ``` + ⚠️ Vérifier la **date** avant de restaurer. Le dernier dump peut être postérieur à + l'incident et contenir déjà les dégâts. + +2. Monter Postgres avec ses extensions (`postgis` **et** `vector`), puis appliquer le schéma : + ```sh + docker compose -f docker-compose.preprod.yaml up -d postgres + dotnet ef database update + ``` + +3. Restaurer : + ```sh + docker exec -i myim_postgres pg_restore -U mym -d my_info_mate \ + --no-owner --no-privileges < .dump + ``` + +4. **Relancer l'indexation des embeddings.** `ContentEmbeddings` est exclu des dumps + (régénérable, et ça divise leur taille par 7). Sans cette étape le guide IA répond + à côté sans le dire. + +5. Vérifier avant d'ouvrir au public : login manager-app, une app visiteur avec sa clé API, + un parcours, une carte, un PDF, **un quiz avec ses questions**. C'est la liste du point 20 + du §1quinquies, et les deux derniers sont ceux qui échouent en silence. + +## Scénario B — un seul client à récupérer + +Un client a supprimé son contenu, ou demande ses données (RGPD). **On ne restaure pas la +prod** : on extrait depuis le dump global. + +```sh +./extract-instance.sh .dump +``` + +Le script restaure le dump dans une base jetable, la réduit à cette instance, la re-dumpe, +et affiche les comptages obtenus. Lire ces comptages : c'est là qu'on voit qu'un quiz est +arrivé sans ses questions. + +Puis réinjecter en connaissance de cause — les ID sont conservés, donc une ligne encore +présente en prod provoquera un conflit de clé. Choisir explicitement entre écraser et +ignorer ; il n'y a pas de bon défaut. + +### Ce que l'extraction ne contient pas + +- **Les médias.** Les blobs vivent dans Firebase Storage, la base n'en garde que le chemin. + Une extraction seule rend un catalogue de liens morts. +- **Les lignes sans `InstanceId`** (logs d'audit système) : elles n'appartiennent à aucun + client, donc à aucun export. C'est volontaire — sans ça elles fuiteraient dans l'export + de *chaque* client. +- `SubscriptionPlans`, `spatial_ref_sys` et `__EFMigrationsHistory` repartent entiers : + données de référence, pas données client. + +## Vérifier une sauvegarde sans rien restaurer + +```sh +./restore-check.sh .dump +``` + +Restaure dans une base jetable, compare les comptages table par table avec la base courante, +puis supprime la base jetable. Aucun effet sur la prod. Écart attendu : `ContentEmbeddings` à 0. + +--- + +## Pièges rencontrés pour de vrai + +**« collation version mismatch »** — bloque la création de toute base, donc toute +restauration. Arrivé deux fois en local (09/08 et 07/09). Dans cet ordre ; le second seul +masque le symptôme sans le corriger : +```sql +REINDEX DATABASE ""; +ALTER DATABASE "" REFRESH COLLATION VERSION; +``` +Peut concerner `template1` et `postgres`, pas seulement la base applicative — et c'est +`template1` qui bloque `createdb`. + +**`pg_restore --list /dev/stdin`** échoue (« did not find magic string ») : un pipe n'est pas +seekable. Sans argument, `pg_restore` lit stdin correctement. + +**`docker exec -i` dans une boucle** consomme le stdin de la boucle. Ne pas passer `-i` quand +la commande n'en a pas besoin. + +**`"InstanceId" <> 'x'` ne supprime pas les `NULL`.** En SQL `NULL <> 'x'` vaut `NULL`, pas +vrai. Utiliser `is distinct from`. Repéré parce que la somme des extractions par client +dépassait le global de 37 lignes. diff --git a/ManagerService/Deployment/backup/backup-postgres.sh b/ManagerService/Deployment/backup/backup-postgres.sh new file mode 100644 index 0000000..4eabae2 --- /dev/null +++ b/ManagerService/Deployment/backup/backup-postgres.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Sauvegarde applicative de la base Postgres, en complément du snapshot OVH. +# Lecture seule vis-à-vis de la base : pg_dump prend un snapshot MVCC, il ne verrouille rien. +set -eu + +CONTAINER=${CONTAINER:-myim_postgres} +DB=${POSTGRES_DATABASE:-my_info_mate} +DB_USER=${POSTGRES_USER:-mym} +OUT=${OUT:-./backups} + +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +FILE="$OUT/${DB}_${STAMP}.dump" + +mkdir -p "$OUT" + +# Les embeddings sont régénérables depuis le contenu CMS : on garde le schéma, pas les vecteurs. +docker exec "$CONTAINER" pg_dump \ + --username="$DB_USER" --dbname="$DB" \ + --format=custom --compress=6 --no-owner --no-privileges \ + --exclude-table-data='public."ContentEmbeddings"' \ + > "$FILE" + +# Un dump tronqué ne doit jamais entrer dans la rotation. +if ! docker exec -i "$CONTAINER" pg_restore --list < "$FILE" > /dev/null; then + echo "Dump illisible, supprimé : $FILE" >&2 + rm -f "$FILE" + exit 1 +fi + +echo "$FILE ($(du -h "$FILE" | cut -f1))" + +# Copie hors du VPS. C'est cette étape qui fait la différence entre un fichier +# et une sauvegarde : un dump posé sur la machine qui héberge la base disparaît +# avec elle. BACKUP_DEST vide = rien n'est protégé, le script le dit. +if [ -z "${BACKUP_DEST:-}" ]; then + echo "BACKUP_DEST vide : le dump reste sur cette machine, il ne protege de rien." >&2 +else + # Un remote rclone s'ecrit "nom:chemin", sans double slash — d'ou le test sur + # le prefixe de chemin et non sur "://", qui ne matchait aucun remote. + case "$BACKUP_DEST" in + /*|./*|../*) mkdir -p "$BACKUP_DEST" && cp "$FILE" "$BACKUP_DEST/" ;; + *:*) rclone copy --immutable "$FILE" "$BACKUP_DEST" ;; + *) mkdir -p "$BACKUP_DEST" && cp "$FILE" "$BACKUP_DEST/" ;; + esac + echo "copie -> $BACKUP_DEST" +fi + +# Rétention : 7 quotidiens. Les hebdo/mensuels sont gérés côté stockage objet +# par une lifecycle rule, pas ici — le VPS ne doit pas être le gardien de l'historique. +ls -1t "$OUT"/${DB}_*.dump 2>/dev/null | tail -n +8 | while read -r old; do + echo "rotation: $old" + rm -f "$old" +done diff --git a/ManagerService/Deployment/backup/check-freshness.sh b/ManagerService/Deployment/backup/check-freshness.sh new file mode 100644 index 0000000..b9f5e97 --- /dev/null +++ b/ManagerService/Deployment/backup/check-freshness.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Le mode de panne reel n'est pas "le script plante", c'est "le script ne tourne +# plus depuis trois semaines et personne ne l'a vu". Un OnFailure n'attrape pas +# ca : il faut regarder l'age de la derniere sauvegarde, pas un code de sortie. +set -eu + +MAX_AGE_HOURS=${MAX_AGE_HOURS:-48} +DEST=${BACKUP_DEST:?export BACKUP_DEST} + +is_remote() { + case "$1" in + /*|./*|../*) return 1 ;; + *:*) return 0 ;; + *) return 1 ;; + esac +} + +if is_remote "$DEST"; then + LATEST=$(rclone lsl "$DEST" | sort -k2 | tail -1) +else + LATEST=$(ls -1t "$DEST" 2>/dev/null | head -1) +fi + +if [ -z "${LATEST:-}" ]; then + echo "Aucune sauvegarde dans $DEST" >&2 + exit 1 +fi + +if is_remote "$DEST"; then + AGE_S=$(( $(date +%s) - $(date -d "$(echo "$LATEST" | awk '{print $2" "$3}')" +%s) )) +else + AGE_S=$(( $(date +%s) - $(stat -c %Y "$DEST/$LATEST") )) +fi + +AGE_H=$(( AGE_S / 3600 )) +if [ "$AGE_H" -gt "$MAX_AGE_HOURS" ]; then + echo "Derniere sauvegarde vieille de ${AGE_H}h (seuil ${MAX_AGE_HOURS}h) : $LATEST" >&2 + exit 1 +fi +echo "OK — derniere sauvegarde il y a ${AGE_H}h" diff --git a/ManagerService/Deployment/backup/dump-mongo-prod.sh b/ManagerService/Deployment/backup/dump-mongo-prod.sh new file mode 100644 index 0000000..321fce8 --- /dev/null +++ b/ManagerService/Deployment/backup/dump-mongo-prod.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Sauvegarde de la prod MongoDB. LECTURE SEULE : ce script ne contient +# volontairement aucun mongorestore, pour qu'aucune erreur de manipulation +# ne puisse écrire dans la prod. +set -eu + +URI=${MONGO_URI:?export MONGO_URI="mongodb://user:pass@host:27017/?authSource=admin"} +DB=${MONGO_DB:-TabletDb} +LABEL=${LABEL:-prod} +OUT=${OUT:-./backups/mongo} +COLLECTIONS=${COLLECTIONS:-Instances Configurations Sections Resources Users Devices} + +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +DEST="$OUT/${LABEL}_${STAMP}" +mkdir -p "$DEST" + +# MSYS_NO_PATHCONV : sous Git Bash, un argument commençant par "/" est réécrit en chemin Windows. +# Variable ignorée ailleurs, le script reste utilisable tel quel sur le VPS. +HOST_DEST=$(cd "$DEST" && { pwd -W 2>/dev/null || pwd; }) +run() { MSYS_NO_PATHCONV=1 docker run --rm -w /out -v "$HOST_DEST:/out" mongo:6 "$@"; } + +# 1. Archive BSON : le vrai filet, types préservés, restaurable dans un Mongo local. +run mongodump --uri="$URI" --db="$DB" --gzip --archive=dump.gz + +# 2. JSON par collection : lisible, diffable, et sert aux comptages de contrôle +# après la bascule Postgres (point 15 du §1quinquies). +for c in $COLLECTIONS; do + run mongoexport --uri="$URI" --db="$DB" --collection="$c" \ + --jsonArray --pretty --out="$DB.$c.json" +done + +echo "--- comptages ---" +for c in $COLLECTIONS; do + n=$(run mongosh "$URI" --quiet --eval "db.getSiblingDB('$DB').$c.countDocuments()") + printf '%-18s %s\n' "$c" "$n" +done | tee "$DEST/counts.txt" + +echo "$DEST" diff --git a/ManagerService/Deployment/backup/extract-instance.sh b/ManagerService/Deployment/backup/extract-instance.sh new file mode 100644 index 0000000..5df2855 --- /dev/null +++ b/ManagerService/Deployment/backup/extract-instance.sh @@ -0,0 +1,87 @@ +#!/bin/sh +# Extrait les données d'une seule instance depuis un dump global. +# +# Ne touche JAMAIS la base de production : le dump est restauré dans une base +# jetable, réduit à l'instance demandée, puis re-dumpé. La cible d'un +# pg_restore est toujours une base neuve. +# +# usage: extract-instance.sh +set -eu + +FILE=${1:?usage: extract-instance.sh } +INSTANCE=${2:?usage: extract-instance.sh } +CONTAINER=${CONTAINER:-myim_postgres} +DB_USER=${POSTGRES_USER:-mym} +OUT=${OUT:-./backups} +SCRATCH="extract_$(date -u +%s)" + +psql() { docker exec "$CONTAINER" psql -qtAX -v ON_ERROR_STOP=1 --username="$DB_USER" --dbname="$1" -c "$2"; } + +docker exec "$CONTAINER" createdb --username="$DB_USER" "$SCRATCH" +trap 'docker exec "$CONTAINER" dropdb --username="$DB_USER" --if-exists "$SCRATCH" >/dev/null 2>&1' EXIT + +docker exec -i "$CONTAINER" pg_restore \ + --username="$DB_USER" --dbname="$SCRATCH" --no-owner --no-privileges < "$FILE" + +if [ "$(psql "$SCRATCH" "select count(*) from \"Instances\" where \"Id\" = '$INSTANCE'")" != "1" ]; then + echo "Instance '$INSTANCE' absente de ce dump. Instances disponibles :" >&2 + psql "$SCRATCH" 'select "Id" || ' ' || "Name" from "Instances"' >&2 || true + exit 1 +fi + +# "is distinct from" et non "<>" : une ligne dont InstanceId est NULL n appartient +# a aucune instance, et "<> x" ne la supprime pas — elle fuiterait dans l export +# de chaque client. Repere en verifiant que la somme des extractions egale le global. +# +# Suppression des enfants avant les parents. Les 7 premières tables n'ont pas +# d'InstanceId : elles se rattachent par leur parent, et c'est là que se cachent +# les oublis silencieux — un quiz sans ses questions, un agenda sans ses events. +psql "$SCRATCH" " +begin; +delete from \"QuizQuestions\" q where + (q.\"SectionQuizId\" is not null and q.\"SectionQuizId\" not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE')) + or (q.\"GuidedStepId\" is not null and q.\"GuidedStepId\" not in (select s.\"Id\" from \"GuidedSteps\" s join \"GuidedPaths\" p on p.\"Id\" = s.\"GuidedPathId\" where p.\"InstanceId\" = '$INSTANCE')) + or (q.\"SectionQuizId\" is null and q.\"GuidedStepId\" is null); +delete from \"MapAnnotations\" a where + (a.\"SectionEventId\" is not null and a.\"SectionEventId\" not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE')) + or (a.\"ProgrammeBlockId\" is not null and a.\"ProgrammeBlockId\" not in (select b.\"Id\" from \"ProgrammeBlocks\" b join \"Sections\" s on s.\"Id\" = b.\"SectionEventId\" where s.\"InstanceId\" = '$INSTANCE')); +delete from \"GuidedSteps\" s where s.\"GuidedPathId\" not in (select \"Id\" from \"GuidedPaths\" where \"InstanceId\" = '$INSTANCE'); +delete from \"EventAgendas\" e where coalesce(e.\"SectionAgendaId\", e.\"SectionEventId\") not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE'); +delete from \"GeoPoints\" g where coalesce(g.\"SectionMapId\", g.\"SectionEventId\") not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE'); +delete from \"ProgrammeBlocks\" b where b.\"SectionEventId\" not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE'); +delete from \"AppConfigurationLinks\" l where + l.\"ConfigurationId\" not in (select \"Id\" from \"Configurations\" where \"InstanceId\" = '$INSTANCE') + or (l.\"ApplicationInstanceId\" is not null and l.\"ApplicationInstanceId\" not in (select \"Id\" from \"ApplicationInstances\" where \"InstanceId\" = '$INSTANCE')) + or (l.\"DeviceId\" is not null and l.\"DeviceId\" not in (select \"Id\" from \"Devices\" where \"InstanceId\" = '$INSTANCE')); + +delete from \"ApplicationInstances\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"GuidedPaths\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"Sections\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"Devices\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"Configurations\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"Resources\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"Users\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"ApiKeys\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"AuditLogs\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"ContentEmbeddings\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"PushNotifications\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"QuestionThemeMonthlies\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"VisitEvents\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"VisitorQuestions\" where \"InstanceId\" is distinct from '$INSTANCE'; +delete from \"Instances\" where \"Id\" <> '$INSTANCE'; +commit;" + +mkdir -p "$OUT" +DEST="$OUT/instance_${INSTANCE}_$(date -u +%Y%m%dT%H%M%SZ).dump" +docker exec "$CONTAINER" pg_dump --username="$DB_USER" --dbname="$SCRATCH" \ + --format=custom --compress=6 --no-owner --no-privileges > "$DEST" + +echo "--- contenu extrait ---" +psql "$SCRATCH" " +select table_name || ' ' || (xpath('/row/c/text()', + query_to_xml('select count(*) as c from public.\"' || table_name || '\"', false, true, '')))[1]::text::int +from information_schema.tables +where table_schema = 'public' and table_type = 'BASE TABLE' +order by table_name" | awk '{ if ($2 > 0) printf " %-24s %s\n", $1, $2 }' + +echo "$DEST ($(du -h "$DEST" | cut -f1))" diff --git a/ManagerService/Deployment/backup/notify.sh b/ManagerService/Deployment/backup/notify.sh new file mode 100644 index 0000000..512879b --- /dev/null +++ b/ManagerService/Deployment/backup/notify.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Point unique de notification. Appele par les units OnFailure. +# A brancher sur le canal reel (webhook, mail, MQTT) — un echo dans le journal +# ne reveille personne. +set -eu +SUBJECT=${1:-"Sauvegarde MyInfoMate en echec"} +BODY=$(cat) + +logger -t myim-backup "$SUBJECT: $BODY" + +if [ -n "${BACKUP_ALERT_WEBHOOK:-}" ]; then + curl -sS --max-time 15 -X POST "$BACKUP_ALERT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + --data "$(printf '{"text":%s}' "$(printf '%s\n%s' "$SUBJECT" "$BODY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \ + || echo "Notification webhook en echec" >&2 +fi diff --git a/ManagerService/Deployment/backup/restore-check.sh b/ManagerService/Deployment/backup/restore-check.sh new file mode 100644 index 0000000..04bb15d --- /dev/null +++ b/ManagerService/Deployment/backup/restore-check.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Restaure un dump dans une base jetable et compare les comptages à la base source. +# C'est ce script qui fait la différence entre "on a des fichiers" et "on a des sauvegardes". +set -eu + +FILE=${1:?usage: restore-check.sh } +CONTAINER=${CONTAINER:-myim_postgres} +DB=${POSTGRES_DATABASE:-my_info_mate} +DB_USER=${POSTGRES_USER:-mym} +SCRATCH="restore_check_$(date -u +%s)" + +psql_scratch() { docker exec "$CONTAINER" psql -qtAX --username="$DB_USER" --dbname="$1" -c "$2"; } + +docker exec "$CONTAINER" createdb --username="$DB_USER" "$SCRATCH" +trap 'docker exec "$CONTAINER" dropdb --username="$DB_USER" --if-exists "$SCRATCH"' EXIT + +docker exec -i "$CONTAINER" pg_restore \ + --username="$DB_USER" --dbname="$SCRATCH" --no-owner --no-privileges < "$FILE" + +TABLES=$(psql_scratch "$DB" "select tablename from pg_tables where schemaname='public' order by tablename") + +printf '%-28s %10s %10s\n' TABLE SOURCE RESTORE +echo "$TABLES" | while read -r t; do + [ -n "$t" ] || continue + a=$(psql_scratch "$DB" "select count(*) from public.\"$t\"") + b=$(psql_scratch "$SCRATCH" "select count(*) from public.\"$t\"") + if [ "$a" = "$b" ]; then mark=""; else mark=" <-- ECART"; fi + printf '%-28s %10s %10s%s\n' "$t" "$a" "$b" "$mark" +done diff --git a/ManagerService/Deployment/backup/systemd/myim-backup-failure.service b/ManagerService/Deployment/backup/systemd/myim-backup-failure.service new file mode 100644 index 0000000..018440e --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-backup-failure.service @@ -0,0 +1,7 @@ +[Unit] +Description=Notification d'echec de sauvegarde + +[Service] +Type=oneshot +EnvironmentFile=/etc/myim-backup.env +ExecStart=/bin/sh -c 'journalctl -u myim-backup.service -n 40 --no-pager | /opt/myinfomate/backup/notify.sh "Sauvegarde PostgreSQL en echec"' diff --git a/ManagerService/Deployment/backup/systemd/myim-backup-freshness.service b/ManagerService/Deployment/backup/systemd/myim-backup-freshness.service new file mode 100644 index 0000000..8135350 --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-backup-freshness.service @@ -0,0 +1,8 @@ +[Unit] +Description=Controle de fraicheur de la derniere sauvegarde +OnFailure=myim-backup-failure.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/myim-backup.env +ExecStart=/opt/myinfomate/backup/check-freshness.sh diff --git a/ManagerService/Deployment/backup/systemd/myim-backup-freshness.timer b/ManagerService/Deployment/backup/systemd/myim-backup-freshness.timer new file mode 100644 index 0000000..18d7ea9 --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-backup-freshness.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Controle quotidien de fraicheur des sauvegardes + +[Timer] +OnCalendar=*-*-* 09:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ManagerService/Deployment/backup/systemd/myim-backup.env.example b/ManagerService/Deployment/backup/systemd/myim-backup.env.example new file mode 100644 index 0000000..4447c5e --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-backup.env.example @@ -0,0 +1,11 @@ +# Copier en /etc/myim-backup.env (chmod 600) et renseigner. +CONTAINER=myim_postgres +POSTGRES_USER=mym +POSTGRES_DATABASE=my_info_mate +OUT=/var/backups/myinfomate +# Destination hors VPS. Vide = le dump reste sur la machine et ne protege de rien. +# Tranche le 2026-09-07 : projet GCP myinfomate-backups, bucket EU multi-region. +# Le remote rclone "gcs" doit etre configure avec la cle du service account +# backup-writer, qui peut deposer et lire mais PAS supprimer (verifie : 403). +BACKUP_DEST=gcs:unov-myinfomate-backups/pg +BACKUP_ALERT_WEBHOOK= diff --git a/ManagerService/Deployment/backup/systemd/myim-backup.service b/ManagerService/Deployment/backup/systemd/myim-backup.service new file mode 100644 index 0000000..a992a13 --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-backup.service @@ -0,0 +1,10 @@ +[Unit] +Description=Sauvegarde PostgreSQL MyInfoMate +# Un echec doit reveiller quelqu'un. cron echoue en silence, systemd non. +OnFailure=myim-backup-failure.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/myim-backup.env +WorkingDirectory=/opt/myinfomate/backup +ExecStart=/opt/myinfomate/backup/backup-postgres.sh diff --git a/ManagerService/Deployment/backup/systemd/myim-backup.timer b/ManagerService/Deployment/backup/systemd/myim-backup.timer new file mode 100644 index 0000000..4effeaf --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Sauvegarde PostgreSQL quotidienne + +[Timer] +OnCalendar=*-*-* 03:15:00 +RandomizedDelaySec=900 +# Rattrape le tir si la machine etait eteinte a l'heure prevue. +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ManagerService/Deployment/backup/systemd/myim-restore-check.service b/ManagerService/Deployment/backup/systemd/myim-restore-check.service new file mode 100644 index 0000000..47d8d73 --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-restore-check.service @@ -0,0 +1,11 @@ +[Unit] +Description=Test de restauration de la derniere sauvegarde +# Une sauvegarde jamais restauree n'est pas une sauvegarde. Le test manuel du +# jour d'installation ne vaut que pour ce jour-la. +OnFailure=myim-backup-failure.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/myim-backup.env +WorkingDirectory=/opt/myinfomate/backup +ExecStart=/bin/sh -c '/opt/myinfomate/backup/restore-check.sh "$(ls -1t "$OUT"/*.dump | head -1)"' diff --git a/ManagerService/Deployment/backup/systemd/myim-restore-check.timer b/ManagerService/Deployment/backup/systemd/myim-restore-check.timer new file mode 100644 index 0000000..1af28b6 --- /dev/null +++ b/ManagerService/Deployment/backup/systemd/myim-restore-check.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Test de restauration mensuel + +[Timer] +OnCalendar=Sun *-*-01..07 04:30:00 +Persistent=true + +[Install] +WantedBy=timers.target