Compare commits

..

3 Commits

Author SHA1 Message Date
Thomas Fransolet
82e61dcded Flux de provisioning d'une base préprod ou prod
Compose Postgres (PostGIS + pgvector épinglé par digest), scripts de bootstrap
du premier SuperAdmin, de lancement local du service et de migration des
données, et le README du flux en huit étapes.

Le port est lié à 127.0.0.1 et non publié : le Postgres précédent, exposé par
un simple "5432:5432" avec mym/mym, a encaissé 1 074 465 échecs
d'authentification entre juin et octobre 2025. Le compte mym n'a jamais été
tenté — c'est le seul hasard qui a sauvé la base. Les limites de log viennent
du même serveur, où un conteneur avait atteint 516 Mo sur un disque de 20 Go.

Le SuperAdmin passe par le token de réinitialisation plutôt que par un INSERT :
les mots de passe étant hachés en scrypt, seule l'application peut produire le
bon format, et personne d'autre que l'opérateur ne connaît le mot de passe.
MigrationController l'exige et la migration n'en crée aucun, MongoDB n'ayant
pas de champ Role.

Éprouvé sur la préprod le 07/09 : 4 instances, 315 sections migrées et 20
signalées sur 335, 2383 ressources, tout réconcilié avec MongoDB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:11:56 +02:00
Thomas Fransolet
c9580fd0a8 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>
2026-09-07 16:11:33 +02:00
Thomas Fransolet
dd00ff9f3b Migration : tolérer un LastName absent côté Mongo
Postgres impose NOT NULL sur Users.LastName, MongoDB n'impose rien. L'utilisateur
test@email.be a un nom nul, et faisait donc échouer le SaveChanges de toute
l'étape — donc annuler la transaction entière et la migration avec.

Trouvé au premier run réel sur la préprod. Un dry run ne pouvait pas le voir :
il n'écrit rien, donc aucune contrainte n'est évaluée. C'est la limite à retenir
de dryRun, et la raison de commencer par la plus petite instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:11:05 +02:00
28 changed files with 1089 additions and 1 deletions

7
.gitignore vendored
View File

@ -64,3 +64,10 @@ migration-data/
# Dumps PostgreSQL (données clients — ne jamais committer) # Dumps PostgreSQL (données clients — ne jamais committer)
*.dump *.dump
*.sql.gz *.sql.gz
# Sauvegardes locales — ne jamais committer de dump de données clients
ManagerService/Deployment/backups/
# Provisioning : seuls le .example et les scripts sont versionnés
ManagerService/Deployment/provisioning/.env
ManagerService/Deployment/provisioning/jwt.txt

View File

@ -306,7 +306,11 @@ namespace ManagerService.Controllers
Email = old.Email, Email = old.Email,
Password = old.Password, Password = old.Password,
FirstName = old.FirstName, FirstName = old.FirstName,
LastName = old.LastName, // Mongo n'impose rien sur le nom, Postgres le veut NOT NULL :
// `test@email.be` a LastName null et faisait échouer tout le
// lot au SaveChanges. Le dry run ne peut pas voir ce défaut —
// il n'écrit rien, donc aucune contrainte n'est évaluée.
LastName = old.LastName ?? string.Empty,
Token = old.Token, Token = old.Token,
DateCreation = old.DateCreation, DateCreation = old.DateCreation,
InstanceId = old.InstanceId, InstanceId = old.InstanceId,

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

View File

@ -0,0 +1,16 @@
# Copier en .env et remplir. Le .env n'est jamais versionne.
#
# Generer un mot de passe fort (PowerShell) :
# -join ((1..32) | % { (([char[]](48..57+65..90+97..122)) | Get-Random) })
#
# Ne JAMAIS reutiliser mym/mym : cette valeur a servi sur un Postgres expose
# publiquement entre juin et octobre 2025, qui a encaisse 1 074 465 tentatives
# d'authentification. Voir README.md, section « Ce qui a mal tourne ».
POSTGRES_USER=myim
POSTGRES_PASSWORD=
POSTGRES_DATABASE=my_info_mate
# Liaison du port. 127.0.0.1 = joignable seulement depuis l'hote et par tunnel SSH.
# En production, laisser 127.0.0.1. Ne jamais mettre 0.0.0.0 ni "5432:5432".
POSTGRES_BIND=127.0.0.1

View File

@ -0,0 +1,50 @@
<#
Genere le SQL qui cree le PREMIER compte SuperAdmin.
Pourquoi ce detour plutot qu'un INSERT direct avec un mot de passe :
les mots de passe sont haches en scrypt (PasswordUtils, 16384 iterations),
impossible a calculer hors .NET. Le token de reinitialisation, lui, est un
simple SHA-256 hexadecimal (PasswordTokenHelper). On laisse donc l'application
faire le hachage scrypt via set-password : le format est garanti correct, et
personne d'autre que toi ne connait le mot de passe.
MigrationController est [Authorize(Policy = SuperAdmin)] et la migration ne
cree aucun SuperAdmin (Mongo n'a pas de champ Role). Ce compte est donc le
prealable oblige a toute migration de donnees.
#>
param(
[string]$Email,
# L'instance doit EXISTER : TokensService.cs:82 fait Instances.Find(user.InstanceId)
# puis lit .PinCode sans test de nullite -> 500 au login sinon.
[string]$InstanceId = 'sa-bootstrap'
)
if ([string]::IsNullOrWhiteSpace($Email)) { $Email = Read-Host "Email du compte SuperAdmin" }
$Email = $Email.ToLower() # Authenticate fait login.email.ToLower()
$token = -join ((1..40) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) })
$sha = [System.Security.Cryptography.SHA256]::Create()
$hash = ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($token)) |
ForEach-Object { $_.ToString('x2') }) -join ''
Write-Host ""
Write-Host "=== ETAPE A : sur une base VIERGE uniquement, creer l'instance d'echafaudage ===" -ForegroundColor Cyan
Write-Host " (a sauter si tu passes -InstanceId d'une instance deja migree)"
Write-Host @"
insert into "Instances" ("Id","Name","DateCreation")
values ('$InstanceId','Bootstrap SuperAdmin', now());
"@
Write-Host "=== ETAPE B : creer le compte ===" -ForegroundColor Cyan
Write-Host @"
insert into "Users"
("Id","Email","Password","FirstName","LastName","Token","DateCreation","InstanceId","Role","PasswordTokenHash","PasswordTokenExpiresAt")
values
('sa-bootstrap-0001','$Email','pending','Super','Admin','',now(),'$InstanceId',0,'$hash', now() + interval '48 hours');
"@
Write-Host "=== ETAPE C : poser le mot de passe (service demarre) ===" -ForegroundColor Cyan
Write-Host " run.cmd 3-set-password.ps1 -Token $token"
Write-Host ""
Write-Host "Le token vaut 48 h et n'est ecrit dans aucun fichier : garde cette fenetre." -ForegroundColor Yellow
Write-Host "N'oublie pas de COMMITER les INSERT : DBeaver n'auto-commite pas toujours." -ForegroundColor Yellow

View File

@ -0,0 +1,55 @@
<#
Lance manager-service EN LOCAL, branche sur un Mongo source et sur la base
cible (preprod ou prod) a travers un tunnel SSH.
Pourquoi en local plutot que sur le serveur : la migration ne demande pas de
tester un deploiement, et builder l'image evite d'affronter en meme temps le
Dockerfile de manager-app (Flutter 3.7 = Dart 2.19, alors que le pubspec exige
>= 3.1) et la source NuGet git.dev-espaces-naturels.lu qui repond 401.
Rien n'est ecrit dans appsettings : les variables d'environnement ASP.NET Core
(double underscore = ':') prennent le dessus. Aucun secret ne part dans le depot.
#>
param(
[string]$MongoUri = "mongodb://localhost:27018", # Mongo SOURCE (dump restaure)
[string]$PgHost = "127.0.0.1",
[int] $PgPort = 5433, # extremite locale du tunnel SSH
[string]$EnvFile = "$PSScriptRoot\.env"
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path $EnvFile)) { throw "Fichier introuvable : $EnvFile (copier .env.example)" }
$cfg = @{}
Get-Content $EnvFile | Where-Object { $_ -match '^\s*[^#].*=' } | ForEach-Object {
$k,$v = $_ -split '=', 2 ; $cfg[$k.Trim()] = $v.Trim()
}
foreach ($k in 'POSTGRES_USER','POSTGRES_PASSWORD','POSTGRES_DATABASE') {
if ([string]::IsNullOrWhiteSpace($cfg[$k])) { throw "$k absent ou vide dans $EnvFile" }
}
# Le repo est 3 niveaux au-dessus : Deployment/provisioning -> Deployment -> ManagerService -> repo
$repo = (Resolve-Path "$PSScriptRoot\..\..\..").Path
Write-Host "Verifications..." -ForegroundColor Cyan
$mongoPort = if ($MongoUri -match ':(\d+)') { [int]$Matches[1] } else { 27017 }
if (-not (Test-NetConnection 127.0.0.1 -Port $mongoPort -InformationLevel Quiet -WarningAction SilentlyContinue)) {
throw "Mongo source injoignable sur $mongoPort. Le conteneur est-il demarre ?"
}
Write-Host " Mongo source $mongoPort : OK" -ForegroundColor Green
if (-not (Test-NetConnection $PgHost -Port $PgPort -InformationLevel Quiet -WarningAction SilentlyContinue)) {
throw "Postgres injoignable sur ${PgHost}:${PgPort}. Le tunnel SSH est-il ouvert ?"
}
Write-Host " Postgres ${PgHost}:${PgPort} : OK" -ForegroundColor Green
$env:ASPNETCORE_ENVIRONMENT = "Development"
$env:ConnectionStrings__TabletDb = $MongoUri
$env:ConnectionStrings__PostgresConnection = "Host=$PgHost;Port=$PgPort;Database=$($cfg['POSTGRES_DATABASE']);Username=$($cfg['POSTGRES_USER']);Password=$($cfg['POSTGRES_PASSWORD'])"
Write-Host ""
Write-Host " TabletDb -> $MongoUri" -ForegroundColor Yellow
Write-Host " PostgresConnection -> ${PgHost}:${PgPort}/$($cfg['POSTGRES_DATABASE'])" -ForegroundColor Yellow
Write-Host " Swagger -> http://localhost:5000/swagger" -ForegroundColor Cyan
Write-Host ""
Set-Location $repo
dotnet run --project ManagerService

View File

@ -0,0 +1,29 @@
<#
Consomme le token de reinitialisation et pose le mot de passe.
Invoke-RestMethod construit le JSON depuis un objet : aucun probleme de
guillemets (cmd ne connait pas l'apostrophe) ni de caracteres speciaux
(&& est l'operateur d'enchainement de cmd).
#>
param(
[Parameter(Mandatory=$true)][string]$Token,
[string]$Password,
[string]$BaseUrl = "http://localhost:5000"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
if ([string]::IsNullOrWhiteSpace($Password)) { $Password = Read-PlainPassword "Mot de passe a poser (8 car. min)" }
if ($Password.Length -lt 8) { throw "Le code exige 8 caracteres minimum." }
try {
$r = Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/Authentication/set-password" `
-ContentType "application/json" `
-Body (@{ token = $Token; newPassword = $Password } | ConvertTo-Json)
Write-Host " $r" -ForegroundColor Green
Write-Host " Etape suivante : run.cmd 4-get-jwt.ps1 -Email <ton email>" -ForegroundColor Cyan
} catch {
Write-Host " ECHEC : $(Get-HttpErrorBody $_)" -ForegroundColor Red
Write-Host " Causes usuelles : token deja consomme, expire (48 h)," -ForegroundColor Yellow
Write-Host " ou INSERT non commite dans le client SQL." -ForegroundColor Yellow
exit 1
}

View File

@ -0,0 +1,35 @@
<# S'authentifie et ecrit le jeton dans jwt.txt (non versionne). #>
param(
[Parameter(Mandatory=$true)][string]$Email,
[string]$Password,
[string]$BaseUrl = "http://localhost:5000"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
if ([string]::IsNullOrWhiteSpace($Password)) { $Password = Read-PlainPassword "Mot de passe" }
try {
$auth = Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/Authentication/Authenticate" `
-ContentType "application/json" `
-Body (@{ email = $Email.ToLower(); password = $Password } | ConvertTo-Json)
} catch {
Write-Host " ECHEC : $(Get-HttpErrorBody $_)" -ForegroundColor Red
Write-Host " 'Object reference not set' = l'instance du compte n'existe pas en base" -ForegroundColor Yellow
Write-Host " (TokensService.cs:82 lit .PinCode sans test de nullite)." -ForegroundColor Yellow
exit 1
}
$jwt = $auth.access_token
if (-not $jwt) { Write-Host "Reponse inattendue :"; $auth | ConvertTo-Json -Depth 4; exit 1 }
Write-Utf8NoBom "$PSScriptRoot\jwt.txt" $jwt
# JsonStringEnumConverter (Startup.cs) serialise les enums en chaine : role vaut
# "SuperAdmin", pas 0. On accepte les deux formes.
$role = "$($auth.role)"
Write-Host " Jeton ecrit dans jwt.txt ($($jwt.Length) caracteres)" -ForegroundColor Green
if ($role -eq "SuperAdmin" -or $role -eq "0") {
Write-Host " Role : $role -- MigrationController acceptera." -ForegroundColor Green
} else {
Write-Host " Role : $role -- il faut SuperAdmin pour migrer." -ForegroundColor Red
}

View File

@ -0,0 +1,71 @@
<#
Joue la migration Mongo -> Postgres.
Sans -Apply : dry run global (n'ecrit rien).
Avec -Apply : joue pour de vrai, instance par instance, de la plus petite a la
plus grande c'est ce qui fait qu'un defaut casse sur le plus petit perimetre
possible. Chaque appel est une transaction unique : soit tout, soit rien.
Un dry run ne peut detecter AUCUNE violation de contrainte : il n'ecrit rien,
donc SaveChanges n'est jamais appele. Un NOT NULL viole ne sort qu'avec -Apply.
C'est arrive le 07/09/2026 (LastName null sur test@email.be).
Compter 2 a 5 min : la sonde HEAD interroge chaque blob Firebase, 30 a la
fois. Elle tourne AUSSI en dry run.
#>
param(
[switch]$Apply,
[string]$InstanceId,
[string]$BaseUrl = "http://localhost:5000"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$jwt = Get-Jwt $PSScriptRoot
$headers = @{ Authorization = "Bearer $jwt" }
function Invoke-Migration($dryRun, $instance) {
$uri = "$BaseUrl/api/migration/run?dryRun=$($dryRun.ToString().ToLower())"
if ($instance) { $uri += "&instanceId=$instance" }
$label = if ($dryRun) { "DRY RUN" } else { "REEL" }
if ($instance) { $label += " / $instance" }
Write-Host "=== $label ===" -ForegroundColor Cyan
$t0 = Get-Date
try {
$r = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType "application/json" -Body "{}"
} catch {
Write-Host " ECHEC HTTP : $(Get-HttpErrorBody $_)" -ForegroundColor Red
Write-Host " Un 500 signifie que la transaction a ete ANNULEE : la base est intacte." -ForegroundColor Yellow
return $false
}
$sec = [int]((Get-Date) - $t0).TotalSeconds
Write-Host " duree : $sec s" -ForegroundColor Gray
$m = $r.migrated
($m.PSObject.Properties | Where-Object { $_.Value -is [int] -and $_.Value -gt 0 } |
ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ", " | ForEach-Object { Write-Host " migre : $_" }
Write-Host " erreurs : $($r.errors.Count) | signalees : $($r.skipped.Count)"
$r.errors | Select-Object -First 5 | ForEach-Object { Write-Host " ! $_" -ForegroundColor Yellow }
if ($r.fatalError) { Write-Host " FATAL : $($r.fatalError)" -ForegroundColor Red; return $false }
Write-Host ""
return $true
}
if (-not $Apply) {
Write-Host "Mode simulation. Ajouter -Apply pour ecrire." -ForegroundColor Yellow
Invoke-Migration $true $InstanceId | Out-Null
exit 0
}
if ($InstanceId) { if (Invoke-Migration $false $InstanceId) { exit 0 } else { exit 1 } }
# ⚠️ L'API ne donne pas le volume par instance : cet ordre est ALPHABETIQUE,
# pas croissant. Or jouer la plus petite d'abord est ce qui fait qu'un defaut
# casse sur le plus petit perimetre — c'est ce qui a permis de trouver le
# LastName null sur 1 utilisateur au lieu de 10, le 07/09/2026.
# Pour maitriser l'ordre, enchainer les appels avec -InstanceId a la main.
Write-Host "Ordre ALPHABETIQUE (l'API ne donne pas les volumes)." -ForegroundColor Yellow
Write-Host "Pour commencer par la plus petite, appeler -InstanceId une par une." -ForegroundColor Yellow
Write-Host ""
$ids = (Invoke-RestMethod -Uri "$BaseUrl/api/Instance" -Headers $headers) |
Sort-Object { $_.name } | Select-Object -ExpandProperty id
foreach ($id in $ids) { if (-not (Invoke-Migration $false $id)) { exit 1 } }
Write-Host "Toutes les instances sont passees." -ForegroundColor Green

View File

@ -0,0 +1,159 @@
# Provisionner une base MyInfoMate — préprod ou production
Le flux complet, du serveur nu à une base Postgres remplie avec les données MongoDB.
Éprouvé le **2026-09-07** sur la préprod `51.77.222.154` : 4 instances, 315 sections,
2383 ressources migrées sans perte.
> Ces scripts sont en PowerShell mais s'exécutent depuis `cmd` via `run.cmd`
> dans `cmd`, un `.ps1` n'est pas exécuté, il est passé à l'association de fichier
> et ne fait rien.
>
> ```
> run.cmd 1-bootstrap-superadmin.ps1 -Email moi@exemple.be
> ```
---
## 0. Prérequis sur le serveur
```bash
docker image prune -f # 7,5 Go récupérés sur la préprod : 3 ans de builds
df -h / # il faut ~1 Go pour les images
```
## 1. La base
```bash
cp .env.example .env # puis remplir POSTGRES_PASSWORD
docker-compose -p myim-preprod -f docker-compose.postgres.yml up -d
```
**Le `-p` n'est pas optionnel.** Compose est en 1.21.0 (2018) sur ce serveur : il ne
connaît pas la clé `name:`, et sans `-p` il déduit le projet du dossier courant —
lancé depuis `/home/debian`, il réutiliserait le volume `debian_postgres-data`,
c'est-à-dire la base cassée d'octobre 2025.
Vérifier que le port n'est **pas** exposé :
```bash
docker port myim_pg # doit afficher 127.0.0.1:5432, jamais 0.0.0.0
```
## 2. Le schéma
Aucun `Migrate()` au démarrage : les migrations s'appliquent explicitement.
Depuis le poste de dev, à travers un tunnel SSH :
```bash
ssh -f -N -L 5433:127.0.0.1:5432 -p 55522 user@serveur
```
```powershell
$env:MIGRATIONS_CONNECTION = "Host=127.0.0.1;Port=5433;Database=my_info_mate;Username=myim;Password=..."
dotnet ef database update --project ManagerService
```
`MIGRATIONS_CONNECTION` est prévue pour ça dans `MyInfoMateDbContextFactory`.
Contrôler ensuite que `postgis` **et** `vector` sont bien installées :
```sql
select extname, extversion from pg_extension;
```
## 3. Le Mongo source
`MigrationController` lit un MongoDB **vivant**, pas des fichiers. Un export JSON
ne suffit donc pas : il faut le restaurer dans un Mongo jetable.
```bash
docker run -d --name myim_mongo_src -p 27018:27017 mongo:6
docker run --rm --network container:myim_mongo_src -v "$PWD:/src:ro" mongo:6 \
mongorestore --uri mongodb://localhost:27017 --gzip --archive=/src/dump.gz --drop
```
⚠️ **Ne jamais pointer sur le Mongo de production.** Les `DatabaseService` exposent
`InsertOne`/`ReplaceOne`/`DeleteOne`, et `dryRun` ne protège que Postgres.
## 4. Le compte SuperAdmin
`MigrationController` est `[Authorize(Policy = SuperAdmin)]`, et la migration n'en
crée aucun — MongoDB n'a pas de champ `Role`. C'est donc le préalable obligé.
```
run.cmd 1-bootstrap-superadmin.ps1 -Email moi@exemple.be
```
Il affiche deux `INSERT` à exécuter dans le client SQL (**penser à commiter**), puis
la commande de l'étape suivante. Les mots de passe étant hachés en scrypt, on laisse
l'application faire le hachage via `set-password` : personne d'autre que toi ne
connaît le mot de passe.
## 5. Le service, en local
```
run.cmd 2-run-service-local.ps1
```
Le brancher en local évite de builder une image, donc d'affronter au même moment le
`Dockerfile` de manager-app (Flutter 3.7 = Dart 2.19, alors que le `pubspec` exige
`>= 3.1`) et la source NuGet `git.dev-espaces-naturels.lu` qui répond 401.
Puis, dans une autre fenêtre :
```
run.cmd 3-set-password.ps1 -Token <le token de l'etape 4>
run.cmd 4-get-jwt.ps1 -Email moi@exemple.be
```
## 6. La migration
```
run.cmd 5-migrate.ps1 # simulation
run.cmd 5-migrate.ps1 -Apply -InstanceId <id> # pour de vrai, une instance
```
**Commencer par la plus petite instance.** Chaque appel est une transaction unique :
en cas d'échec, rien n'est écrit.
> ⚠️ **Un dry run ne peut détecter aucune violation de contrainte** : il n'écrit rien,
> donc `SaveChanges` n'est jamais appelé. Un `NOT NULL` violé ne sort qu'au run réel.
> C'est exactement ce qui est arrivé le 07/09/2026 — `LastName` null sur
> `test@email.be` — et commencer par la plus petite instance l'a fait apparaître
> en 3 secondes sur 1 utilisateur au lieu de 10.
Compter 2 à 5 min : la sonde HEAD interroge chaque blob Firebase, 30 à la fois.
Elle tourne **aussi** en dry run.
## 7. Le backfill des tailles
```
POST /api/Resource/backfill-storage?dryRun=true puis dryRun=false
```
La sonde HEAD est **instable sous charge** : une ressource peut arriver à
`SizeBytes = 0` alors que son blob existe. Le 07/09, le backfill a rattrapé une
ressource sur 2383 et confirmé 3 vrais 404. Un `SizeBytes` à 0 n'est donc jamais
une preuve de blob manquant.
## 8. Nettoyage et vérifications
```sql
-- rattacher le SuperAdmin à une vraie instance, puis supprimer l'échafaudage
update "Users" set "InstanceId" = '<instance réelle>' where "Id" = 'sa-bootstrap-0001';
delete from "Instances" where "Id" = 'sa-bootstrap';
```
Puis `pg_dump -Fc` (voir `../backup/`), et les vérifications qui ne se voient qu'à
l'œil : login manager, une app visiteur avec sa clé API, un parcours, une carte,
un PDF, un quiz avec ses questions.
---
## Ce qui a mal tourné, et qu'on ne refait pas
| | |
|---|---|
| **`5432:5432`** sur le Postgres précédent | exposé à l'internet avec `mym`/`mym` du 06/06 au 22/10/2025 : **1 074 465 échecs d'authentification** sur autant de noms distincts. Le compte `mym` n'a jamais été tenté — c'est le seul hasard qui a sauvé la base. D'où le `127.0.0.1` du compose |
| **Aucune limite de log** | un seul conteneur avait atteint 516 Mo sur un disque de 20 Go |
| **Image `postgres:16` nue** | sans PostGIS, la migration `UpdateCoordinatesToGeom` ne passe pas : le schéma était bloqué à 14 migrations sur 68 |
| **`mym`/`mym`** | ne jamais réutiliser ce couple |

View File

@ -0,0 +1,37 @@
# Fonctions partagees par les scripts d'appel HTTP.
# PowerShell 5.1 ne remplit pas ErrorDetails sur une erreur HTTP : sans lire le
# flux de reponse on n'obtient qu'un « 400 Bad Request » opaque, alors que le
# message du serveur est la seule information utile.
function Get-HttpErrorBody($err) {
if ($err.ErrorDetails.Message) { return $err.ErrorDetails.Message }
try {
$resp = $err.Exception.Response
if ($resp) {
$reader = New-Object IO.StreamReader($resp.GetResponseStream())
$body = $reader.ReadToEnd(); $reader.Close()
if ($body) { return $body }
}
} catch { }
return $err.Exception.Message
}
function Read-PlainPassword($prompt) {
$secure = Read-Host $prompt -AsSecureString
[Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
}
# Set-Content -Encoding utf8 ecrit un BOM en PowerShell 5.1, et un BOM dans un
# en-tete Authorization invalide le jeton.
function Write-Utf8NoBom($path, $text) {
[System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($false)))
}
function Get-Jwt($dir) {
$p = Join-Path $dir "jwt.txt"
if (-not (Test-Path $p)) { throw "jwt.txt absent. Lancer d'abord 4-get-jwt.ps1" }
# [string] est indispensable : la surcharge Replace(char, char) refuse une
# chaine vide. Sans le cast, on tombe dessus et l'appel echoue.
(Get-Content $p -Raw).Replace([string][char]0xFEFF, '').Trim()
}

View File

@ -0,0 +1,48 @@
# Base PostgreSQL de MyInfoMate — preprod comme production.
#
# Lancement (toujours avec -p, voir README) :
# docker-compose -p myim-preprod -f docker-compose.postgres.yml up -d
#
# ⚠️ Compose du serveur 51.77.222.154 = 1.21.0 (2018) : format 3.6 maximum,
# et la cle « name: » n'existe pas. D'ou le -p obligatoire.
version: '3.6'
services:
postgres:
build:
context: ..
dockerfile: Dockerfile.postgres
image: myinfomate/postgres-pgvector:16-3.4
container_name: ${POSTGRES_CONTAINER:-myim_pg}
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DATABASE}
# Liaison sur la boucle locale : joignable par tunnel SSH, jamais depuis
# l'internet. C'est la lecon du Postgres precedent, expose par un simple
# "5432:5432" et martele pendant quatre mois et demi.
ports:
- "${POSTGRES_BIND:-127.0.0.1}:5432:5432"
volumes:
- pg-data:/var/lib/postgresql/data
networks:
- myim
restart: unless-stopped
# Aucun conteneur du serveur n'avait de limite : un seul log avait atteint
# 516 Mo sur un disque de 20 Go, et a probablement tue la base.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pg-data:
networks:
myim:

View File

@ -0,0 +1,10 @@
@echo off
REM Lance un script .ps1 de ce dossier depuis cmd, ou un .ps1 ne s'execute pas.
REM run.cmd 1-bootstrap-superadmin.ps1 -Email moi@exemple.be
if "%~1"=="" (
echo Usage : run.cmd ^<script.ps1^> [arguments]
echo.
dir /b "%~dp0*.ps1"
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0%~1" %2 %3 %4 %5 %6 %7 %8 %9