Compare commits
3 Commits
3d23e05351
...
82e61dcded
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e61dcded | ||
|
|
c9580fd0a8 | ||
|
|
dd00ff9f3b |
7
.gitignore
vendored
7
.gitignore
vendored
@ -64,3 +64,10 @@ 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/
|
||||
|
||||
# Provisioning : seuls le .example et les scripts sont versionnés
|
||||
ManagerService/Deployment/provisioning/.env
|
||||
ManagerService/Deployment/provisioning/jwt.txt
|
||||
|
||||
@ -306,7 +306,11 @@ namespace ManagerService.Controllers
|
||||
Email = old.Email,
|
||||
Password = old.Password,
|
||||
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,
|
||||
DateCreation = old.DateCreation,
|
||||
InstanceId = old.InstanceId,
|
||||
|
||||
129
ManagerService/Deployment/backup/README.md
Normal file
129
ManagerService/Deployment/backup/README.md
Normal 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.
|
||||
99
ManagerService/Deployment/backup/RESTORE.md
Normal file
99
ManagerService/Deployment/backup/RESTORE.md
Normal 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.
|
||||
53
ManagerService/Deployment/backup/backup-postgres.sh
Normal file
53
ManagerService/Deployment/backup/backup-postgres.sh
Normal 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
|
||||
40
ManagerService/Deployment/backup/check-freshness.sh
Normal file
40
ManagerService/Deployment/backup/check-freshness.sh
Normal 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"
|
||||
38
ManagerService/Deployment/backup/dump-mongo-prod.sh
Normal file
38
ManagerService/Deployment/backup/dump-mongo-prod.sh
Normal 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"
|
||||
87
ManagerService/Deployment/backup/extract-instance.sh
Normal file
87
ManagerService/Deployment/backup/extract-instance.sh
Normal 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))"
|
||||
16
ManagerService/Deployment/backup/notify.sh
Normal file
16
ManagerService/Deployment/backup/notify.sh
Normal 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
|
||||
29
ManagerService/Deployment/backup/restore-check.sh
Normal file
29
ManagerService/Deployment/backup/restore-check.sh
Normal 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
|
||||
@ -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"'
|
||||
@ -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
|
||||
@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Controle quotidien de fraicheur des sauvegardes
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 09:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@ -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=
|
||||
10
ManagerService/Deployment/backup/systemd/myim-backup.service
Normal file
10
ManagerService/Deployment/backup/systemd/myim-backup.service
Normal 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
|
||||
11
ManagerService/Deployment/backup/systemd/myim-backup.timer
Normal file
11
ManagerService/Deployment/backup/systemd/myim-backup.timer
Normal 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
|
||||
@ -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)"'
|
||||
@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Test de restauration mensuel
|
||||
|
||||
[Timer]
|
||||
OnCalendar=Sun *-*-01..07 04:30:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
16
ManagerService/Deployment/provisioning/.env.example
Normal file
16
ManagerService/Deployment/provisioning/.env.example
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
29
ManagerService/Deployment/provisioning/3-set-password.ps1
Normal file
29
ManagerService/Deployment/provisioning/3-set-password.ps1
Normal 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
|
||||
}
|
||||
35
ManagerService/Deployment/provisioning/4-get-jwt.ps1
Normal file
35
ManagerService/Deployment/provisioning/4-get-jwt.ps1
Normal 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
|
||||
}
|
||||
71
ManagerService/Deployment/provisioning/5-migrate.ps1
Normal file
71
ManagerService/Deployment/provisioning/5-migrate.ps1
Normal 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
|
||||
159
ManagerService/Deployment/provisioning/README.md
Normal file
159
ManagerService/Deployment/provisioning/README.md
Normal 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 |
|
||||
37
ManagerService/Deployment/provisioning/_common.ps1
Normal file
37
ManagerService/Deployment/provisioning/_common.ps1
Normal 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()
|
||||
}
|
||||
@ -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:
|
||||
10
ManagerService/Deployment/provisioning/run.cmd
Normal file
10
ManagerService/Deployment/provisioning/run.cmd
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user