From 82e61dcded0b7d1278e37a8b5e361a0495e62dc2 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Mon, 7 Sep 2026 16:11:56 +0200 Subject: [PATCH] =?UTF-8?q?Flux=20de=20provisioning=20d'une=20base=20pr?= =?UTF-8?q?=C3=A9prod=20ou=20prod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + .../Deployment/provisioning/.env.example | 16 ++ .../provisioning/1-bootstrap-superadmin.ps1 | 50 ++++++ .../provisioning/2-run-service-local.ps1 | 55 ++++++ .../provisioning/3-set-password.ps1 | 29 ++++ .../Deployment/provisioning/4-get-jwt.ps1 | 35 ++++ .../Deployment/provisioning/5-migrate.ps1 | 71 ++++++++ .../Deployment/provisioning/README.md | 159 ++++++++++++++++++ .../Deployment/provisioning/_common.ps1 | 37 ++++ .../provisioning/docker-compose.postgres.yml | 48 ++++++ .../Deployment/provisioning/run.cmd | 10 ++ 11 files changed, 514 insertions(+) create mode 100644 ManagerService/Deployment/provisioning/.env.example create mode 100644 ManagerService/Deployment/provisioning/1-bootstrap-superadmin.ps1 create mode 100644 ManagerService/Deployment/provisioning/2-run-service-local.ps1 create mode 100644 ManagerService/Deployment/provisioning/3-set-password.ps1 create mode 100644 ManagerService/Deployment/provisioning/4-get-jwt.ps1 create mode 100644 ManagerService/Deployment/provisioning/5-migrate.ps1 create mode 100644 ManagerService/Deployment/provisioning/README.md create mode 100644 ManagerService/Deployment/provisioning/_common.ps1 create mode 100644 ManagerService/Deployment/provisioning/docker-compose.postgres.yml create mode 100644 ManagerService/Deployment/provisioning/run.cmd diff --git a/.gitignore b/.gitignore index c276d7a..d36b5ac 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ migration-data/ # 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 diff --git a/ManagerService/Deployment/provisioning/.env.example b/ManagerService/Deployment/provisioning/.env.example new file mode 100644 index 0000000..b846578 --- /dev/null +++ b/ManagerService/Deployment/provisioning/.env.example @@ -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 diff --git a/ManagerService/Deployment/provisioning/1-bootstrap-superadmin.ps1 b/ManagerService/Deployment/provisioning/1-bootstrap-superadmin.ps1 new file mode 100644 index 0000000..485f41a --- /dev/null +++ b/ManagerService/Deployment/provisioning/1-bootstrap-superadmin.ps1 @@ -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 diff --git a/ManagerService/Deployment/provisioning/2-run-service-local.ps1 b/ManagerService/Deployment/provisioning/2-run-service-local.ps1 new file mode 100644 index 0000000..255ec22 --- /dev/null +++ b/ManagerService/Deployment/provisioning/2-run-service-local.ps1 @@ -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 diff --git a/ManagerService/Deployment/provisioning/3-set-password.ps1 b/ManagerService/Deployment/provisioning/3-set-password.ps1 new file mode 100644 index 0000000..927ba4d --- /dev/null +++ b/ManagerService/Deployment/provisioning/3-set-password.ps1 @@ -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 " -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 +} diff --git a/ManagerService/Deployment/provisioning/4-get-jwt.ps1 b/ManagerService/Deployment/provisioning/4-get-jwt.ps1 new file mode 100644 index 0000000..b9a0a07 --- /dev/null +++ b/ManagerService/Deployment/provisioning/4-get-jwt.ps1 @@ -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 +} diff --git a/ManagerService/Deployment/provisioning/5-migrate.ps1 b/ManagerService/Deployment/provisioning/5-migrate.ps1 new file mode 100644 index 0000000..7bc8c4b --- /dev/null +++ b/ManagerService/Deployment/provisioning/5-migrate.ps1 @@ -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 diff --git a/ManagerService/Deployment/provisioning/README.md b/ManagerService/Deployment/provisioning/README.md new file mode 100644 index 0000000..b1819f3 --- /dev/null +++ b/ManagerService/Deployment/provisioning/README.md @@ -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 +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 # 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" = '' 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 | diff --git a/ManagerService/Deployment/provisioning/_common.ps1 b/ManagerService/Deployment/provisioning/_common.ps1 new file mode 100644 index 0000000..3dfb643 --- /dev/null +++ b/ManagerService/Deployment/provisioning/_common.ps1 @@ -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() +} diff --git a/ManagerService/Deployment/provisioning/docker-compose.postgres.yml b/ManagerService/Deployment/provisioning/docker-compose.postgres.yml new file mode 100644 index 0000000..16a7784 --- /dev/null +++ b/ManagerService/Deployment/provisioning/docker-compose.postgres.yml @@ -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: diff --git a/ManagerService/Deployment/provisioning/run.cmd b/ManagerService/Deployment/provisioning/run.cmd new file mode 100644 index 0000000..cbf51bb --- /dev/null +++ b/ManagerService/Deployment/provisioning/run.cmd @@ -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 ^ [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