Scripts de sauvegarde Postgres

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-07 16:11:33 +02:00
parent dd00ff9f3b
commit c9580fd0a8
17 changed files with 570 additions and 0 deletions

3
.gitignore vendored
View File

@ -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/

View File

@ -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.

View File

@ -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/<fichier>.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 < <fichier>.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-global>.dump <instanceId>
```
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 <fichier>.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 "<base>";
ALTER DATABASE "<base>" 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.

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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 <dump-global> <instanceId>
set -eu
FILE=${1:?usage: extract-instance.sh <dump-global> <instanceId>}
INSTANCE=${2:?usage: extract-instance.sh <dump-global> <instanceId>}
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))"

View File

@ -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

View File

@ -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 <fichier.dump>}
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

View File

@ -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"'

View File

@ -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

View File

@ -0,0 +1,9 @@
[Unit]
Description=Controle quotidien de fraicheur des sauvegardes
[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -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=

View File

@ -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

View File

@ -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

View File

@ -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)"'

View File

@ -0,0 +1,9 @@
[Unit]
Description=Test de restauration mensuel
[Timer]
OnCalendar=Sun *-*-01..07 04:30:00
Persistent=true
[Install]
WantedBy=timers.target