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