Files
ScadaBridge/docs/plans/2026-09-28-site-secrets-artifact-deploy-live-gate.md
T
Joseph Doherty 2650f79472 test(rig): site secrets gate covers the follow-ups — standby KEK mismatch on the health report, removal audit, CLI exit codes — PASS 9/9
Also: DeploymentDeadlineParityTests' stub replies with a real zero-failure summary (the artifact
aliases now derive their exit code from failureCount), and register row 127 reflects the merge
and the two new replicated site tables.
2026-09-28 21:46:49 -04:00

17 KiB
Raw Blame History

Site secrets ride the artifact deploy — live gate (Task 13)

Date: 2026-09-28/29 (UTC 00:28–00:50 on 2026-09-29) · Branch: ScadaBridge-site-secrets worktree · Result: bash docker/verify-site-secrets.sh → PASS 8/8 (on the image carrying fix ad19d4b1). The first run, on the pre-fix image, was PASS 7/8 — check 5 found a product defect, fixed below.

Design: 2026-09-28-site-secrets-artifact-deploy-design.md.

1. Method

Rig bring-up

docker/deploy.sh was deliberately not run from the worktree (its relative bind mounts would rehome to the worktree). Instead:

  1. bash docker/build.sh from the worktree → scadabridge:latest built from branch code.
  2. docker compose -f <worktree>/docker/docker-compose.yml --project-directory ~/Desktop/ScadaBridge/docker up -d --force-recreate — the branch compose (new Secrets__* env: central Store:Provider=SqlServer + shared KEK, site-a shared KEK, site-b/site-c a deliberately mismatched KEK, no hub keys anywhere) with bind mounts resolved against the primary checkout's docker/ directory. git diff 2aede140 HEAD --stat -- docker/ shows only docker-compose.yml changed, so the mounted node appsettings are identical to the branch's. docker compose config resolved project name docker, the running project, so the containers were replaced, not duplicated.
  3. All 8 nodes answered /health/ready 200 (centrals on 9001/9002; sites probed on their internal 8084 from the Traefik container, which is on scadabridge-net).

The same procedure was repeated after the fix (image rebuild + force-recreate); all 8 ready within ~10 s.

Boot-log review (secrets)

  • Central (both): no secrets-related warning or error. Secrets:Store:Provider=SqlServer against the rig's existing ZbSecretsHub database came up clean; no leftover-key warning (the rig carries no Secrets:GrpcHub:* or Replication:Mode).
  • site-a: no secrets-related warning or error. Its local secrets.db still holds six rows swept from the retired hub in August (hub-gate-smoke-2/3, shared-gate-*, KEK sha256:f94d4ae2c4b1) — inert, not ledgered by the mirror, so never touched by it.
  • site-b / site-c: booted cleanly with a KEK for the first time; no secrets warnings.
  • The only secrets line after the second recreate was an expected ERR on site-b-a — the mirror's startup pass re-verifying the sb-gate-mxgw row left live there by the pre-fix run (the defect's residue); the next gate run's pre-clean removed it.
  • Unrelated noise present on every boot: singleton-proxy "not found after 30 s" warnings during cluster formation, one akka-cluster ... Removed health-check ERR per pair during the recreate, OPC UA auto-accept warnings.

How each surface is driven

Surface Tool Notes
Central secret create / rotate / delete Central UI /admin/secrets (ZB.MOM.WW.Secrets.Ui), headless Chromium via Python Playwright The supported operator path; there is no CLI or management verb for central secrets, and the library's secret CLI only talks to a SQLite store headless. Value goes through the environment into the masked input, never argv/stdout.
Data connections, artifact deploys, audit query ScadaBridge CLI via Traefik (localhost:9000) data-connection create/update/delete, site deploy-artifacts --site-id, audit-config query --action DeploySiteSecrets.
Artifacts badge (Current / Out of date) Sites page, Re-check pressed, badge aria-label read The pending-updates evaluation has no CLI surface and is Blazor-interactive.
Central row sqlcmd read-only on ZbSecretsHub.zbsecrets.secret Revision, KekId, HASHBYTES('SHA2_256', ciphertext).
Site node stores docker cp of /app/data/scadabridge-secrets.db (library table secret) and /app/data/site-localdb.db (deployed_secrets, mirrored_secrets, data_connection_definitions) + WAL, read by host python sqlite3 The image has no sqlite3. The -shm is not copied (SQLite rebuilds the wal-index); each read is repeated until two consecutive copies agree.
Out-of-band seed on site-a-a (check 5) The library's own secret CLI (ZB.MOM.WW.Secrets.Cli, published from scadaproj main = 0.6.2) run in a throwaway scadabridge:latest container with --volumes-from scadabridge-site-a-a, Secrets__SqlitePath=/data/scadabridge-secrets.db and site-a's KEK i.e. the library's normal local SQLite write path on that node's store — not a raw SQL insert.

Gate objects: central secret sb-gate-mxgw; never-created sb-gate-missing; out-of-band sb-gate-oob on site-a-a; MxGateway connections sb-gate-conn-a (site-a) and sb-gate-conn-b (site-b), unbound, endpoint http://sb-gate-no-such-gateway:5000. The trap-based teardown (also run up-front as a pre-clean) deletes both connections, redeploys site-a and site-b so the removal reaches them, deletes the central secret, removes the out-of-band secret with the secret CLI, then redeploys artifacts fleet-wide; the run ended with all three sites Current. The library tombstones rather than hard-deletes, so after teardown the stores hold only is_deleted = 1 rows for the gate names (verified: site-a-a, site-a-b, site-b-a and central all deleted; no gate connection left).

2. Per-check evidence (run 2, PASS 8/8)

# Check Evidence
1 Secret + referencing connection → site-a Out of date, b/c unchanged sb-gate-mxgw created via /admin/secrets (central r3, KEK sha256:f94d4ae2c4b1 — the revision continues past run 1's tombstone); after sb-gate-conn-a ("ApiKey":"secret:sb-gate-mxgw") badges site-a: out of date, site-b: current, site-c: current.
2 Deploy site-a → success, secretsDeployed: 1, Current {"siteId":"site-a","success":true,"secretsDeployed":1} (0.2 s); badge current.
3 Both nodes at the central revision; standby ≤ 30 s site-a-a r3, ciphertext SHA-256 707ae73ba872… == central (visible 4.0 s after the deploy returned — dominated by the docker-cp read loop; the active node applies synchronously before replying); site-a-b converged 5.5 s after the deploy returned; its mirrored_secrets ledger at r3.
4 Rotate → Out of date → deploy → both at new revision central r3 → r4 with new ciphertext; badge out of date; redeploy secretsDeployed 1; site-a-a r4 (0.3 s), site-a-b r4 at 6.8 s; badge back to current.
5 Drop the reference → both nodes drop it; out-of-band survives sb-gate-oob seeded on site-a-a via the secret CLI; sb-gate-conn-a switched to a literal ApiKey; deploy succeeded (no secretsDeployed); site-a-a no longer holds sb-gate-mxgw (0.2 s), site-a-b at 9.0 s; sb-gate-oob still live on site-a-a; deployed_secrets.sb-gate-mxgw.is_removed = 1 (kept for the standby).
6 Missing secret → fail closed, stays Out of date, site untouched Error Secret 'sb-gate-missing' referenced by data connection(s) sb-gate-conn-a is not in the central secret store; badge out of date; stamped hash, both nodes' secret rows, deployed_secrets, ledgers and the site's sb-gate-conn-a definition byte-identical before/after.
7 Same reference on site-b (mismatched KEK) → fails naming KekId Error Secret 'sb-gate-mxgw' (kek sha256:f94d4ae2c4b1) did not decrypt on this node — the node's master key differs from central's; site-b not stamped (out of date).
8 Audit rows; no material in logs Exactly 4 DeploySiteSecrets/SiteSecrets rows with distinct deploymentIds: site-a ok sb-gate-mxgw@r3, site-a ok @r4, site-a fail missing sb-gate-missing, site-b fail @r4; the no-secret deploy of check 5 wrote none. Grep of docker logs --since <gate start> on all 8 nodes and of the audit payloads for both plaintext values, the out-of-band value and both base64 ciphertexts: 0 hits.

Standby convergence (site-a-b, seconds after the deploy call returned)

Run Check 3 (first deploy) Check 4 (rotation) Check 5 (removal)
1 (pre-fix) 9.4 5.4 never (defect)
2 (fixed) 5.5 6.8 9.0

All inside the 30 s window, consistent with LocalDb CDC replication of deployed_secrets plus the standby's 15 s DeployedSecretsMirrorInterval. The figures include up to ~1–2 s of read-loop overhead (docker cp + double read per poll).

3. Defects found

3.1 Dropping a site's last secret: reference never removed the secret (fixed, ad19d4b1)

Run 1, check 5: after the removal deploy both site-a nodes still held sb-gate-mxgw r1 live, deployed_secrets.is_removed = 0, ledger r1. Cause: ArtifactDeploymentService.BuildDeployArtifactsCommandAsync shipped Secrets = null whenever a site had no references ("null-when-empty"), and the site deliberately treats null as "central shipped no secrets" (pre-feature contract) and touches nothing — so the full-set soft-remove never ran. Fix: a secrets-aware central always ships a list (empty when there are no references). Old sites ignore proto field 9; the artifact hash is unchanged (empty and null hash identically); SecretsDeployed stays null and no audit row is written when nothing shipped. Regression test NoSecretReferences_ShipsEmptySecretsList_SoTheSiteRemovesPreviouslyDeployedOnes (red on the old code, green on the fix). Spec updated in Component-DeploymentManager.md, Component-SiteRuntime.md and the design doc's as-built notes.

3.2 ManagementActorTests did not compile a valid proxy (fixed, 1444f859)

Four DeployArtifacts_* tests built an NSubstitute proxy of ArtifactDeploymentService with the pre-Task-6 constructor arguments; they now pass a SiteSecretsCollector. Test-only.

4. Test suite

dotnet build ZB.MOM.WW.ScadaBridge.slnx: 0 warnings, 0 errors. dotnet test ZB.MOM.WW.ScadaBridge.slnx (30 test assemblies, run against the live rig): 10 006 tests, 9 979 passed, 27 failed — 4 were the ManagementActorTests proxy failures fixed in 1444f859 (ManagementService.Tests re-run: 668/668), the other 23 are Playwright E2E. After the fix commit DeploymentManager.Tests is 234/234 (one new test). Every non-Playwright assembly is green.

Playwright residue (23 of 184):

Class Tests Classification
RoleNavigationTests AdminUser_DoesNotSeeDeploymentSection, AdminUser_DoesNotSeeDesignSection, AdminUser_SeesHealthDashboard_NotDeploymentMonitoring, DeploymentUser_DoesNotSeeAdminSection, DeploymentUser_DoesNotSeeDesignSection, DeploymentUser_SeesMonitoringButNotConfigurationAuditLog, DesignUser_DoesNotSeeAdminSection, DesignUser_DoesNotSeeDeploymentSection, DesignUser_SeesHealthDashboard_NotDeploymentMonitoringOrAudit Known env-only — role-negative under DisableLogin=true
LoginTests UnauthenticatedUser_RedirectsToLogin Known env-only — DisableLogin
AuditLogPageTests PermissionGating_DesignerWithoutOperationalAudit_IsDeniedAccess Known env-only — DisableLogin
DeploymentActionTests Delete_Instance_RemovesFromTree, Deploy_Instance_ShowsOutcomeToast, Disable_Instance_ShowsOutcomeToast, Enable_Instance_ShowsOutcomeToast Known residue (pagination helper — row not found)
TopologyAreaTests CreateArea_ViaToolbar_AppearsInTreeAndPersists, Diff_OnDeployedInstance_OpensDialog, MoveArea_UnderAnotherArea_ShowsMovedToast, MoveInstance_ToArea_ShowsMovedToast, RenameArea_EnterCommits_PersistsNewName, RenameArea_Escape_RevertsToOriginalLabel Known residue (SignalR/visibility timeout)
AuditLogPageTests ExportCsv_LinkVisibleForAuditExportUserAndTriggersDownload Not on the known list — Download event timeout (30 s), reproducible in isolation. Audit export is untouched by this branch; central logs showed audit-ingest SQL timeouts against the large AuditLog table at the time. Judged environmental.
TransportImportTests ImportSyntheticBundle_AppliesAndShowsAuditDrillIn Not on the known list — reproducible in isolation; central logs Cannot insert duplicate key row in object 'dbo.Sites' ... (site-a) during the import, i.e. the synthetic bundle's site mapping collides with the rig's existing site-a. Transport and site mapping are untouched by this branch. Judged rig-data related.

5. Observations (not gated)

  • Standby of an unreplicated site pair never receives deployed secrets. deployed_secrets reaches the standby only through LocalDb CDC replication; on site-b (deliberately unreplicated on this rig) the gate secret landed in site-b-a's store only — site-b-b's store never saw it. A failover on an unreplicated pair would leave the new active node without the site's secrets until the next artifact deploy. This follows from the design (§3.5) but is worth stating in the topology guide: secrets-bearing sites should run with LocalDb replication on.
  • The CLI's site deploy-artifacts exits 0 even when a site result is success: false; the gate reads the JSON siteResults rather than the exit code.
  • Re-adding a name that is tombstoned at central continues its revision (run 2 started at r3 after run 1's r1 + delete), which is why the gate compares revisions to the live central row rather than to fixed numbers.

6. Re-run after final-review fixes (2026-09-28)

Code under test: d6411863 (fixes 6f1787ab mirror drift heal + unreadable-row isolation, b1b49bb3 secrets-before-DCL ordering, 67a3950f shared secret: parser + hash-parity test). Same procedure as §1: bash docker/build.sh from the worktree (image sha256:9de1db5c…), then docker compose -f <worktree>/docker/docker-compose.yml --project-directory ~/Desktop/ScadaBridge/docker up -d --force-recreate; all 8 nodes answered /health/ready 200 within ~15 s.

bash docker/verify-site-secrets.sh → PASS 8/8. Standby convergence on site-a-b (seconds after the deploy call returned): check 3 = 8.1, check 4 = 5.4, check 5 = 9.0. Check 8: exactly the 4 expected DeploySiteSecrets rows, 0 plaintext/ciphertext hits across all 8 node logs and the audit payloads. Teardown left all three sites Current.

Tests before the rig run: dotnet build ZB.MOM.WW.ScadaBridge.slnx 0 errors; Commons.Tests 875/875, SiteRuntime.Tests 892/892, DeploymentManager.Tests 235/235, DataConnectionLayer.Tests 383/383, Host.Tests 593/593.

7. Follow-ups run (2026-09-28)

Code under test: branch fix/site-secrets-followups — per-site secret-store outage handling and removal-only audit rows (29049c4e), artifact-deploy CLI exit codes (ef03a0dc), per-node mirror status on the site health report (0289d511, merged in 4e640918). Image rebuilt from the worktree with bash docker/build.sh, all 8 nodes recreated from the primary checkout's compose file.

Hub-era secrets removed from site-a (review M8), before the run. The retired gRPC hub had left nine rows dated 2026-08-07 in both site-a nodes' local stores (/app/data/scadabridge-secrets.db, table secret): six live (hub-gate-smoke-2, hub-gate-smoke-3, shared-gate-051, shared-gate-2, shared-gate-2b, shared-gate-final) and three tombstones (expander-gate-pepper, hub-gate-smoke-1, shared-gate-1). Nothing in the rig config referenced them. With the site-a pair stopped together, each node's file was edited from a throwaway python:3.13-alpine container mounting that node's data volume (DELETE … WHERE name IN (…) AND updated_utc < '2026-08-08', then wal_checkpoint(TRUNCATE)); 9 rows deleted per node, only the sb-gate-* gate tombstones left. Both nodes came back ready, site-a-a active. site-b/site-c never ran the hub and had no such rows; QA and vd03 never enabled replication.

The gate grew to nine checks:

  • new check 5 (M1): site-a-b is recreated with the site-b/site-c dev KEK (a compose override, removed again in the same check and in teardown), and central's health site --identifier site-a must show node-b failing sb-gate-mxgw while node-a is clean, then both clean once site-a-b has site-a's KEK again;
  • check 6 (was 5) also asserts the reply's secretsRemoved names the dropped secret;
  • checks 7 and 8 (were 6 and 7) also assert the CLI exits 1;
  • check 9 (was 8) now expects five DeploySiteSecrets rows — the removal-only deploy of check 6 writes one, with removed: [sb-gate-mxgw].

bash docker/verify-site-secrets.sh → PASS 9/9. Before the check, central reported both site-a node rows with no failures; site-b and site-c (unreplicated) report their active node only. With the wrong KEK, node-b's sb-gate-mxgw failure appeared on central's site-a report 57 s after site-a-b was back in the cluster (mirror pass 15 s + status reporter 30 s + health report 30 s), and cleared 57 s after the KEK was restored. Standby convergence: check 3 = 4.3 s, check 4 = 4.2 s, check 6 = 15.1 s. Audit: the 5 expected rows, 5 distinct deployment ids; 0 plaintext/ciphertext hits in all 8 node logs and the audit payloads. Teardown left all three sites Current and site-a-b on site-a's KEK.

Not exercised live: the central secret-store outage (review M5) — taking the shared SQL store down on the rig also takes down central's configuration database. Covered by unit tests (SiteSecretsCollectorTests, ArtifactDeploymentServiceTests.StoreOutage_*, ComputeCurrentHashes_StoreOutage_*, RetryForSite_StoreOutage_*).

Tests: dotnet build ZB.MOM.WW.ScadaBridge.slnx 0 errors / 0 warnings; every non-Playwright test project green (CLI.Tests 553 after updating DeploymentDeadlineParityTests' stub server to reply with a real zero-failure summary — a bare {} now reads as an invalid artifact-deploy summary and exits 1). Playwright not run.