Both driven from the Mac over the winrm CLI (WinRM 5985 + SMB 445, no helper on the boxes). Migration 53->55; the first Central boot encrypts DataConnections.PrimaryConfiguration (dp:protocol:v1:), so rollback is the COPY_ONLY DB restore, not a binary swap. QA: all four nodes stopped together, one shared-DB migration, 2 min 35 s window, preflight against a restored clone. Claude-Session: https://claude.ai/code/session_01RgthVLVU5CJ8SUuEwruTkt
12 KiB
Deployment record — wonder-app-vq03 + wonder-app-vq04 (QA) — 2026-09-10 — afb27e77 → 2b9a7c3c
Point-in-time record of a ScadaBridge binary + schema upgrade of the QA two-host topology:
a real central pair (central-a = wonder-app-vq03 10.220.158.2, central-b = wonder-app-vq04
10.220.158.3, :8085, Akka 8081, CentralGrpcPort 8086) and a site-a pair (node-a/node-b on
the same two boxes, gRPC :8083, health :8084, Akka 8082, LocalDb replication ON), each box running
ScadaBridge-Central, ScadaBridge-Site and ScadaBridge-LDAP (native services, env in the
service key) out of E:\ApiInstall\ScadaBridge. Shared ConfigDb ScadaBridge on
wonder-sql-vq03 (both centrals; DataProtection key ring lives in the same DB). Performed from the
Mac entirely via the winrm CLI (WinRM 5985/NTLM for commands; the zip had been pushed over SMB
beforehand) — no psbridge, no servecli, nothing installed on the boxes. Same build as the DEV record
2026-09-10-wonder-app-vd03-2b9a7c3c.md (read that for the build/delta description).
1. Binary + schema upgrade
| Item | Value |
|---|---|
| Build deployed | main @ 2b9a7c3c (from afb27e77, 2026-08-21; 253 commits) — all four nodes. ProductVersion 1.0.0+2b9a7c3ca05fde8fe58f647d8f91e5ab9452807b on Host, CLI and all 25 ZB.MOM.WW.ScadaBridge.*.dll on both boxes. |
| Headline change | Arch-review remediation (13 High + 33 Medium + 46 Low), management API / CLI parity (CLI gains shared-schema + flow-debug), Flow Debugger v1.4, DataConnections protocol config encrypted at rest (dp:protocol:v1:). |
| Artifact | E:\psrem-stage\2b9a7c3c\scadabridge.zip on both boxes, 125 543 056 B, SHA-256 371B3271A57EC1BA… re-verified on-box; unpacked to E:\psrem-stage\2b9a7c3c\unpacked (app 1029 files, cli 223); staged exe ProductVersion checked before the swap. |
| EF migrations applied | 2 (53 → 55) from the from→to script sb-mig-53to55.sql (20260819201210 → 20260909203046, NOT idempotent, identical to vd03's) via sqlcmd -C -b -I on vq03: 20260906034113_AddApiMethodTraceAllCalls, 20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration (THROW 51002 guard passed — 0 duplicate UniqueName; IX_Instances_SiteId_UniqueName dropped, IX_Instances_SiteId + unique IX_Instances_UniqueName created; Primary/BackupConfiguration widened to nvarchar(max)). |
| Coordinated data upgrade | The first Central boot on the new binary (vq03, 12:19:43) ran DataConnectionSecretUpgrade: DataConnections id 1 (MxGateway) PrimaryConfiguration 210 chars plaintext → 426 chars dp:protocol:v1:, BackupConfiguration NULL, DataProtectionKeys unchanged at 2. afb27e77 cannot read the protected row and the migration Down throws over it — rollback is the DB restore, not a binary swap. |
| New required config | None. The box's three appsettings*.json were kept, restored after the swap and SHA-256-matched on both boxes (vq03: F94000B1…/92524782…/4CBAF66C…; vq04: F94000B1…/2FD3EC4C…/D0C1AF05…). Service registry environment untouched (reg export of both service keys to E:\psrem-stage\2b9a7c3c\backup\). ApiKeyStore:Provider deliberately still UNSET (per-node SQLite) — separate planned cutover; the boot [WRN] Inbound API keys use the per-node SQLite store on a 2-node central cluster is real on QA (unlike vd03) and is the reason for that cutover. |
| DB backup | F:\MSSQL\BACKUP\ScadaBridge_pre-2b9a7c3c_20260910T121009.bak on wonder-sql-vq03 (COPY_ONLY, CHECKSUM, COMPRESSION; RESTORE VERIFYONLY … WITH CHECKSUM valid; 688 MB logical / 113 MB compressed), taken 12:11:41 with everything still running. |
| Binary backup | E:\ApiInstall\ScadaBridge\app.bak.20260910T121009 + cli.bak.20260910T121009 on each box (old afb27e77 binaries + appsettings); _appsettings-keep-20260910T121009 copies alongside. |
| Notes on box | E:\ApiInstall\ScadaBridge\_DEPLOY-NOTES-20260910T121009.txt on each box (baseline, preflight, window, verify, rollback). |
Preflight (vq04, before the window, 12:14–12:17)
The staged Host was booted as an isolated single-node Central on vq04 (:18085, Akka 18081,
seeds = self + a dead 18087, BootstrapGuard off, CentralGrpcPort 18086, own copies of the
inbound-api-key store and secrets.db, real service env otherwise) against a clone
ScadaBridge_pf2b9a7c3c restored from the pre-upgrade backup (WITH MOVE) and migrated 53 → 55
with the same script. Result: /health/ready 200 in 15 s (memberCount 1, 5/5 singletons),
POST /management no-auth 401, clone row encrypted 210 → 426 dp:protocol:v1:, DataProtectionKeys
2, live central cluster stayed at memberCount 2 / unreachable 0 throughout. Clone dropped and
pf-app removed afterwards (pf-data\ logs left on vq04).
Near miss, recorded for next time: attempt 1 set the clone catalog with
$csb.InitialCatalog = … on a SqlConnectionStringBuilder, which PowerShell 5.1 rejects
(Keyword not supported: 'InitialCatalog') — with $ErrorActionPreference='Continue' the script
went on and started the Host against the live ScadaBridge DB. The production-mode
pending-migration check (MigrationHelper.ApplyOrValidateMigrationsAsync) refused the boot with
"2 pending migration(s)" before DataConnectionSecretUpgrade or any write; the live DB was verified
untouched (53 migrations, row still plaintext). Use $csb['Database'] = …, assert
$csb.InitialCatalog equals the clone name, and run the setup under Stop.
Sequence (maintenance window)
Stop Site vq04 → Site vq03 → Central vq04 → Central vq03 (all four stopped, no
ZB.MOM.WW.ScadaBridge.Host process on either box, 0 sessions on the DB) → migration from vq03 →
swap app\/cli\ on vq03 then vq04 → start Central vq03 (was active) → Central vq04 → Site vq03 →
Site vq04. Pairs stopped and started together (no rolling upgrade of a site pair; no mixed
versions against the encrypted row).
| Step | Local time | Duration |
|---|---|---|
Stop ScadaBridge-Site vq04 (window opens) |
12:17:57 | 30 s |
Stop ScadaBridge-Site vq03 |
12:18:28 | 10 s |
Stop ScadaBridge-Central vq04 |
12:18:46 | 10 s |
Stop ScadaBridge-Central vq03 |
12:18:58 | 10 s |
| Migration 53 → 55 (vq03 → wonder-sql-vq03) | 12:19:15 | 1 s |
| Swap vq03 / vq04 (rename, copy, restore appsettings, SHA match) | 12:19:21 / 12:19:30 | 7 s / 8 s |
Start Central vq03 → /health/ready 200 (protocol-row encryption ran here) |
12:19:43 → 12:19:57 | 14 s |
| Start Central vq04 → ready (Joining, memberCount 2) | 12:20:02 → 12:20:10 | 8 s |
| Start Site vq03 → ready | 12:20:15 → 12:20:23 | 8 s |
| Start Site vq04 → ready (window closes) | 12:20:28 → 12:20:32 | 4 s |
Downtime (first stop → last site ready): 2 min 35 s.
Verification (12:21–12:22, both boxes)
:8085 /health/ready200 Healthy on both centrals — database, akka-cluster Up, memberCount 2, unreachable 0, all 5 required singletons;/health/active200 on vq03 (oldest Up member), 503 Standby on vq04 — same active node as before the window.:8084 /health/ready200 Healthy on both sites — akka-cluster Up memberCount 2,localdbHealthy withreplicationConnected:true, backlog 0 on both (vq03 briefly showedreplicationConnected:false/ backlog 2 while vq04's site was still down — expected, cleared on vq04 start);/health/active200 vq03 / 503 vq04.- DB: 55 migrations (max
20260909203046_…), indexesIX_Instances_AreaId, IX_Instances_SiteId, IX_Instances_TemplateId, IX_Instances_UniqueName;DataConnectionsid 1 LEN 426dp:protocol:v1:;DataProtectionKeys2; 4 instances (Z28061, Z28062 Enabled; Z28063, ZIPSEN Disabled). - Auth posture unchanged:
POST /managementno-auth → 401 before and after;GET :8085/200 (title ScadaBridge); on-box CLI with the QA test user works —--version=2b9a7c3c,instance list→ 4 rows, groups vs old:+shared-schema +flow-debug. - Snapshots: baseline (12:10, via the CLI against the active central) Z28061(1) attrs=244
Good=244, Z28062(2) attrs=244 Good=244, Z28063(9)/ZIPSEN(7) attrs=0 (not deployed); after:
identical on the first poll at 12:21:46 (vq03) and 12:22:27 (vq04) — the DCL re-subscribed
(
Instance Z28061/Z28062 subscribing to 244 tags on connection MxGatewayat 12:20:26). - Site runtime:
Loaded 2 deployed configs (2 enabled) from SQLite; reconcile passes on node-a and node-b0 fetched, 0 failed, 0 orphan(s). - Logs since the new boot: vq03 central 3 ERR (all the known first-probe
member status: Removed), vq04 central 0; vq03 site 5 ERR (allmember status: Removed), vq04 site 1 (same); the only FTL is the outgoing build'shost terminated unexpectedlyat 12:18:37 on vq04's site during its stop (known, cosmetic). WRNs are the usual set (HTTP/2-without-TLS notice,RequireHttpsCookiedisabled, the SQLite-key-store warning,Debug snapshot for unknown instance Z28063/ZIPSENfrom the snapshot probe of the two undeployed instances). - QA Overview on vq03
:5320/health/ready200, stillWatching 10 instance(s) across 4 application(s).
Pitfalls hit (for next time)
SqlConnectionStringBuilderproperty sets do not work in PS 5.1 ($b.InitialCatalog = …throwsKeyword not supported); use the indexer with the real keyword ($b['Database']) and assert.- When a preflight targets a clone, put the catalog assertion before
Start-Processand run the setup section with$ErrorActionPreference = 'Stop'— see the near miss above. - Masking by key name is not enough: a key like
ScadaBridge__Database__ConfigurationDbcarries aPassword=inside its value. Mask values by pattern (Password=[^;]*) too. - The migration script from
dotnet ef migrations script <from> <to>does not contain the<from>id — checking that it "starts from"20260819201210means checking the DB'sMAX(MigrationId)equals it, which it did. - Windows reports today's open Serilog file as 0 bytes (
Get-ChildItem);Get-Contentreads it fine — do not treat the 0-byte size as "nothing logged".
2. Rollback
Both boxes, in this order:
Stop-Service ScadaBridge-Siteon vq04 then vq03;Stop-Service ScadaBridge-Centralon vq04 then vq03; confirm noZB.MOM.WW.ScadaBridge.Hostprocess remains on either box.- From vq03 (
sqlcmd -C -b):RESTORE DATABASE [ScadaBridge] FROM DISK = 'F:\MSSQL\BACKUP\ScadaBridge_pre-2b9a7c3c_20260910T121009.bak' WITH REPLACE— required: the protected protocol row is unreadable byafb27e77and the migrationDownthrows over it, so a binary-only rollback is not possible for this build. - On each box:
app→app.failed,app.bak.20260910T121009→app;cli→cli.failed,cli.bak.20260910T121009→cli. Start-Service ScadaBridge-Centralon vq03 then vq04 (wait for:8085/health/ready200), thenScadaBridge-Siteon vq03 then vq04 (:8084).
Service env untouched; .reg exports of both service keys are in E:\psrem-stage\2b9a7c3c\backup\
on each box if a service ever has to be re-created.
3. Outstanding
ApiKeyStore:Providerstill unset on the QA pair (per-node SQLite inbound-API keys on a real 2-node central — a failover rejects inbound clients until keys are re-issued): the SqlServer cutover is the separate planned record.- ZIPSEN (id 7) and Z28063 (id 9) remain non-deployed (Disabled) instances.
- Housekeeping once the build has soaked: remove
app.bak.*/cli.bak.*/_appsettings-keep-*from 2026-08-2x and this deploy underE:\ApiInstall\ScadaBridge, andE:\psrem-stage\2b9a7c3c\(zip + unpacked ≈ 500 MB per box).