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