Commit Graph

2984 Commits

Author SHA1 Message Date
Joseph Doherty ef03a0dca7 fix(cli): artifact-deploy commands exit 1 when any site failed
site deploy-artifacts and deploy artifacts rendered the per-site summary and
always exited 0, so a script could not tell a failed site (unreachable,
missing or unreadable secret, partial apply) from a clean deploy. Both now
route through the failureCount rule deploy site already used.
2026-09-28 21:23:52 -04:00
Joseph Doherty 23a139a46c test(rig): site secrets live gate re-run after final-review fixes — PASS 8/8 2026-09-28 21:11:37 -04:00
Joseph Doherty d641186397 docs(site-secrets): final-review doc fixes — replication prerequisite, upgrade impact, audit shape
I2: topology guide + Component-SiteRuntime + register row 127 state that a site pair using
deployed secrets must run LocalDb replication. I3: row 127 + topology guide state the upgrade
impact (whole artifact deploy fails and reads Out of date until referenced secrets exist
centrally). M6: audit row carries secrets/missing, no removed field; removal-only deploys write
no central row. M2/M3/I1 as-built notes; CLAUDE.md secrets bullet corrected.
2026-09-28 21:08:34 -04:00
Joseph Doherty 67a3950f74 fix(site-secrets): one shared secret: reference parser for scanner and MxGateway adapter
Final review M3: SecretReferenceScanner.TryParseSecretReference (trimmed, case-insensitive
prefix, trimmed name, empty = not a reference, ${secret:} never matches) is used by the scanner
and by MxGatewayDataConnection's ApiKey resolution, so a padded or case-variant reference
resolves the name central ships. A bare 'secret:' ApiKey now fails closed. M7: unit test pins
stamped hash == ComputeCurrentArtifactHashesAsync with a non-empty Secrets list.
2026-09-28 21:08:34 -04:00
Joseph Doherty b1b49bb36c fix(site-secrets): mirror secrets before pushing artifact data connections to the DCL
Final review M2: HandleDeployArtifacts now sends ApplyArtifactDataConnectionsToDcl after the
shipped secrets are written and mirrored (from a finally, so the push still goes out if the
secrets step fails), so a connection created by the same deploy resolves a newly shipped
secret on first connect. Definitions are still stored at the same point; response semantics
unchanged.
2026-09-28 21:08:34 -04:00
Joseph Doherty 6f1787abe1 fix(site-secrets): mirror heals same-revision local drift and isolates unreadable rows
Final review I1: a name already ledgered at the shipped revision is now read back every
pass; a missing, tombstoned or edited local row is healed through the mirror-owned re-stamp
path (bump, apply, read back, invalidate) before verify, instead of a local delete being
misreported as a KEK mismatch that blocked every later deploy. Drift logs Warning once per
occurrence. M4: row_json that cannot be parsed becomes a Failed entry for that name via the
new SiteStorageService.GetDeployedSecretsTolerantAsync; an unreadable removed row is still
honoured by name. M10: invalidator ctor doc corrected.
2026-09-28 21:08:34 -04:00
Joseph Doherty 9835ef8be3 test(rig): site secrets artifact-deploy live gate PASS 8/8 2026-09-28 20:47:57 -04:00
Joseph Doherty ad19d4b158 fix(site-secrets): ship an empty Secrets list, not null, when a site references no secrets
The site applies DeployArtifactsCommand.Secrets as the full set (rows absent from it are
soft-removed and DeployedSecretsMirror deletes them on both nodes), while null means
'central shipped no secrets' and touches nothing. The command builder shipped null for a
site with no references, so dropping the LAST secret: reference never removed the secret
from either site node. Found by the Task 13 live gate (check 5: site-a-a and site-a-b
both still held sb-gate-mxgw r1, deployed_secrets.is_removed=0 after the removal deploy).

Central now always ships a list. Old sites ignore proto field 9; the artifact hash is
unchanged (empty and null hash identically); SecretsDeployed stays null and no
DeploySiteSecrets audit row is written when nothing shipped. Regression test
NoSecretReferences_ShipsEmptySecretsList_SoTheSiteRemovesPreviouslyDeployedOnes (red
before, green after). Spec notes in Component-DeploymentManager/SiteRuntime + the design
doc's as-built section.
2026-09-28 20:43:36 -04:00
Joseph Doherty 1444f85919 fix(site-secrets): ManagementActorTests build the ArtifactDeploymentService proxy with the new SiteSecretsCollector ctor arg
Task 6 appended SiteSecretsCollector to ArtifactDeploymentService's constructor; the
NSubstitute class proxy in ManagementActorTests still passed the old ten arguments, so
the four DeployArtifacts_* tests failed with 'Could not find a constructor'. Caught by
the Task 13 full-solution run.
2026-09-28 20:32:26 -04:00
Joseph Doherty 0ccf68951d docs: site secrets ride the artifact deploy; gRPC hub retired in ScadaBridge 2026-09-28 20:22:45 -04:00
Joseph Doherty bc29dc412b fix(secrets): review round 1 — pre-host expander reads the same store the runtime serves, raw-string Replication:Enabled parse, leftover-key warning
- SecretsRegistration: UsesCentralSharedStore predicate shared by AddScadaBridgeSecrets and the
  new AddPreHostExpander, so a Central node's Layer-A ${secret:} expander composes against the
  shared SQL store whenever Secrets:Store:Provider=SqlServer does — closing the gap where a
  central migrated off the retired Grpc topology would boot-time-resolve config from per-node
  SQLite while serving the shared store.
- Program.cs: expander block now calls AddPreHostExpander + resolves the store-agnostic
  ISecretsStoreMigrator seam; stale Grpc-hub/replication comment rewritten.
- DeployedSecretsMirror.cs: stale "gRPC-hub follower half" doc comment rewritten to state the
  now-permanent local-SQLite-only invariant.
- StartupValidator: Secrets:Replication:Enabled read as a raw string (any non-empty, non-"false"
  value rejected) instead of GetValue<bool>, which threw a raw binder crash on "yes"/"1"; added a
  CollectWarnings entry for leftover Secrets:Replication/Secrets:GrpcHub child keys once Enabled
  is false/unset.
- Tests: +15 (AddPreHostExpander/UsesCentralSharedStore wiring, Replication:Enabled edge values,
  leftover-key warnings). Full solution build and full Host.Tests green (593/593).
2026-09-28 20:10:17 -04:00
Joseph Doherty 2bac13170c feat(secrets)!: retire the gRPC secrets hub in ScadaBridge — sites get secrets via Deploy Artifacts; rig central on the shared SQL store 2026-09-28 19:52:26 -04:00
Joseph Doherty 52cd4fb096 feat(site): artifact apply writes deployed secrets and answers SecretsApplied 2026-09-28 19:29:55 -04:00
Joseph Doherty 838fb4177b fix(site): DeployedSecretsMirror — ledger-owned rows recover past clock skew; real-cipher recovery test
Fix round 2 (review task-9-rereview-1.md), both Minors:

1. Clock skew. ApplyAndConfirmAsync now decides the tombstone-vs-out-of-band split by LEDGER
   OWNERSHIP (any mirrored_secrets entry for the name, at any revision), not merely by whether
   the local row happens to be a tombstone. A live local row this node already owns but whose
   ciphertext no longer matches the newly shipped revision (e.g. an earlier tombstone-recovery
   re-stamp landed ahead of a since-shipped central UpdatedUtc, under site/central clock skew)
   is now re-stamped and re-applied exactly like a tombstone, instead of being misreported as
   someone else's "newer local value" and left permanently un-converged. The out-of-band Failed
   message is now only ever produced for a name that is NOT in the ledger. New test:
   Reconcile_ClockSkewedRecoveredRow_StillConvergesOnNextCentralRevision (a skewed future
   re-stamp followed by a genuinely new central revision converges and re-invalidates the
   cache).
2. Real-cipher integration test. Added DeployedSecretsMirrorRealStackTests, running the mirror
   against the REAL ZB.MOM.WW.Secrets stack (SqliteSecretStore + AesGcmEnvelopeCipher +
   DefaultSecretResolver over a real temp-file SQLite database, plus a hand-written
   IMasterKeyProvider test double mirroring the library's own) instead of in-memory fakes:
   encrypt -> SecretArtifact -> apply -> delete (tombstone) -> re-add the same artifact -> the
   recovered row resolves to the original plaintext. Pins the "UpdatedUtc/Revision are not
   AEAD-bound" property against the real cipher rather than by source inspection alone.
   Test-only package reference: ZB.MOM.WW.Secrets (SiteRuntime itself still depends only on
   ZB.MOM.WW.Secrets.Abstractions).

Verify: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsMirrorTests (25/25); full SiteRuntime.Tests suite 878/878.
2026-09-28 19:19:22 -04:00
Joseph Doherty 4d7d6456e3 fix(site): DeployedSecretsMirror — confirm-then-ledger LWW apply, per-row isolation, cache invalidation
Fix round 1 (review task-9-review.md), all rulings:

1. (Important 1) ApplyReplicatedAsync is no longer trusted blindly. Every apply is
   confirmed by a store.GetAsync read-back before ledgering: a tombstoned local row
   (routine remove-then-re-add) is safely re-stamped past the tombstone and re-applied
   (confirmed neither UpdatedUtc nor Revision is AEAD-bound in AesGcmEnvelopeCipher, so
   the bump cannot break decryption); a live local row with different ciphertext (a
   strictly-newer out-of-band secret) is left untouched, never ledgered, never verified
   against, and reported with a distinct Failed message so a later central tombstone can
   never delete it via this mirror.
2. (Important 2) Per-row try/catch (rethrow only on ct.IsCancellationRequested) so one
   poisoned row (e.g. an unrecognised ContentType from a newer central) no longer blocks
   every row after it.
3. (Important 3) Optional ISecretCacheInvalidator, invalidated after every successful
   apply/delete and before verify, so a rotated/removed secret's plaintext cannot be
   served stale for the remainder of the resolver's TTL.
4. (Minor 4) Loop and VerifyAsync OperationCanceledException handling now filters on
   ct/stoppingToken.IsCancellationRequested specifically, rather than swallowing every OCE.
5. (Minor 5) Class remarks note the mirror assumes a node-local ISecretStore; Task 11
   will reject SqlServer secrets replication on the Site role.
6. (Minor 6/7) Added cancellation + concurrent-callers tests, dropped the redundant
   FailFor (tests now use ThrowFor/ReturnNullFor to exercise distinct decrypt-failure
   paths), and Dispose() now disposes the gate SemaphoreSlim.

Verify: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsMirrorTests (24/24); full SiteRuntime.Tests suite 876/876.
2026-09-28 19:07:48 -04:00
Joseph Doherty 25c25ccef9 feat(site): DeployedSecretsMirror — per-node apply + decrypt-verify of deployed secrets 2026-09-28 18:52:44 -04:00
Joseph Doherty a35ee663c8 feat(site): deployed_secrets (LocalDb-replicated ciphertext) + node-local mirrored_secrets ledger 2026-09-28 18:36:49 -04:00
Joseph Doherty 437b02a43c test(artifacts): cover RetryForSiteAsync secrets reconciliation and DeploySiteSecrets audit row (fix round 1) 2026-09-28 18:28:27 -04:00
Joseph Doherty 00c89aff12 feat(artifacts): never stamp a site current unless it applied every secret; DeploySiteSecrets audit row 2026-09-28 18:20:54 -04:00
Joseph Doherty 75b2a7244c feat(artifacts): central ships each site's referenced secrets (ciphertext) and fails closed on a missing one
ArtifactDeploymentService now resolves each site's secret: references (via
the new SiteSecretsCollector, wrapping ISecretStore) into DeployArtifactsCommand.Secrets,
never plaintext. A referenced secret that is absent or tombstoned fails
that site's deploy before anything is sent, with a named error, while the
rest of the batch still deploys; ComputeCurrentArtifactHashesAsync instead
folds a MissingSecrets marker into the hash so such a site reads Out of
date. Central logs what it ships (name@revision) and errors on what's
missing, never material.

Also updates the two other direct ArtifactDeploymentService construction
sites (PendingUpdatesEvaluatorTests, CentralUI SitesPageTests) for the new
constructor parameter.
2026-09-28 18:07:17 -04:00
Joseph Doherty a57c36f504 fix(secrets): review round 1 — gate the clustered-central secrets warning on Replication:Enabled, name the real trigger in the shared-store connection-string message, pin Grpc-vs-Store:Provider precedence 2026-09-28 17:56:30 -04:00
Joseph Doherty 35235ecfed feat(secrets): Secrets:Store:Provider=SqlServer — central pair shares one SQL secret store without the hub 2026-09-28 17:44:14 -04:00
Joseph Doherty ebc55c5f72 feat(artifacts): secrets and missing-secret markers in the artifact revision hash 2026-09-28 17:32:42 -04:00
Joseph Doherty 8aecd55db5 test: pin pre-secrets artifact hash 2026-09-28 17:31:17 -04:00
Joseph Doherty 738e458755 test(secrets): cover ReferencedBy dedup within one connection and case-insensitive bootstrap exclusion 2026-09-28 17:29:52 -04:00
Joseph Doherty 01cb1f5652 feat(secrets): SecretReferenceScanner derives a site's secret refs from its data-connection configs 2026-09-28 17:27:28 -04:00
Joseph Doherty bf7d2db0b1 feat(artifacts): additive Secrets list + SecretsApplied on the artifact deploy contract (proto field 9) 2026-09-28 17:22:57 -04:00
Joseph Doherty b6287ae307 docs(plans): site secrets ride the artifact deploy — design + implementation plan 2026-09-28 17:17:05 -04:00
Joseph Doherty 2aede140e2 deploy(qa): b3021e7c to vq03/vq04 (55->57, artifacts deployed); SimpleAlarmStatusRequest copied from vd03; register row 126 closed 2026-09-28 11:12:05 -04:00
Joseph Doherty 5cc09b08ef deploy(vd03): b3021e7c — script file access live (AllowedRoots E:\ApiInstall\ScadaBridge\recipes); gate 31/31, rig 8/8
Second vd03 window of the day: no migrations, appsettings.Site.json gains ScriptFileAccess:AllowedRoots
(pushed over SMB before the swap), sample recipes in E:\ApiInstall\ScadaBridge\recipes. The Delmia
test receiver now reads + parses the downloaded file; real HTTP R100 -> 12 values loaded, R900 -> rejected
with line numbers. verify-mes-test-machine.sh: D1's cleared check reads through BTDB, so it only runs
with group C (the rig runs VERIFY_GROUPS=AD).
2026-09-28 10:45:54 -04:00
Joseph Doherty b3021e7c5f feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync /
ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton
ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a
System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call
throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully
qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no
reparse point on the file or any directory below the root, size cap before and during the read.
Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts
joins the default script imports on every surface so it binds by short name. Mirrored on
ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled).
Inbound (central) scripts do not get it.

Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted
read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine
SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's
type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh
group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS
selects groups (rig: AD).
2026-09-28 10:36:42 -04:00
Joseph Doherty 0af0672597 deploy(vd03): fe89363b + migrations 56/57; in-memory MES/Delmia/alarm-API test machines
vd03 (DEV) upgraded 2b9a7c3c -> fe89363b via the deployment-only winrm channel: COPY_ONLY backup,
55->57 delta script (AddAlarmMesReportable, AddSiteArtifactRevision), ~53 s window, snapshots
identical to baseline, artifacts deployed. Register row 126 now covers the QA pair only.

docker/seed-mes-test-machine.sh (idempotent) builds a standalone TestMesMachine base template -
Left/Right TestMESReceiver + TestDelmiaReceiver (prod attribute names, STATIC), computed alarms
with IsMesReportable, CvdReactor-compatible MesMoveIn/MesMoveOut/ProcessRecipeDownload/AlarmStatus
scripts ported to SetAsync+WaitAsync, and Galaxy-side simulators with reject/no-response knobs -
plus instances SBTEST01/02 and the SimpleAlarmStatusRequest (was a stub) / AlarmStatusRequest
inbound methods in the legacy /mes/(simple)alarmstatus shapes, resolving machines through BTDB.
BTDB rows SBTEST01=990001 / SBTEST02=990002 added on vd03.
docker/verify-mes-test-machine.sh drives the real inbound methods via flow-debug: PASS 25/25.
2026-09-28 10:10:17 -04:00
Joseph Doherty fe89363b1a docs(claude): winrm re-enabled for deployment activities only (2026-09-28)
The 2026-09-11 hold (WinRM exec tripped the wonder boxes' security software) is lifted for
deploys only: preflight, backup, stop/swap/start, migration, post-deploy verify, rollback.
Ad-hoc diagnostics and deployed-version checks use SMB/HTTP/SQL instead. psbridge stays retired.
2026-09-28 09:25:10 -04:00
Joseph Doherty fad833e8d1 docs(former-api-specs): legacy MES alarm API — code-verified behaviour notes
Checked against the legacy mesapi source (MesNotifier/MesServices/Tag) plus ServiceStack/OrmLite
6.0.2 in a scratch harness:
- unhandled exceptions are an HTTP 500 ServiceStack ErrorResponse, not WasSuccessful=false
- no matching alarms = success with an empty list; failures don't short-circuit
- AckDT is null only for a null MXAccess value, not 'when unacked'
- tag type-conversion failures are silent (InAlarm false -> alarm drops out)
- simplealarmstatus SAPID edge cases: null never matches, "" matches, duplicates -> arbitrary TOP 1
- 'test' API keys hit the production BatchDB (TestDb routing never reaches MesNotifier)
- latent SetResult-after-timeout race in the MXAccess data-change callback
2026-09-28 09:25:10 -04:00
Joseph Doherty fb54ed428b docs(register): row 126 — TODO roll migrations 56/57 (AddAlarmMesReportable, AddSiteArtifactRevision) to vd03/vq03/vq04 with the 55→57 delta script
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:43:18 -04:00
Joseph Doherty ac833f5ace docs: pending-updates plan — all 12 tasks completed; rig gate 13/13 + Playwright 3/3 on the merged tip
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:38:32 -04:00
Joseph Doherty 3a21cfc2f5 fix(central-ui): pending-updates indicator starts polling once the async auth resolve completes
OnParametersSetAsync awaits the principal. On a real cookie session that await
suspends, so the FIRST render ran with _visible still false and
OnAfterRenderAsync(firstRender: true) skipped EnsurePollingStarted(); the
render that followed the resolve was firstRender: false, and nothing else
started the poll unless AuthenticationStateChanged happened to fire. The block
therefore rendered and sat on 'Checking for updates…' for the life of the
circuit. OnParametersSetAsync now starts polling itself when visible (the
start is idempotent and the OnAfterRender call is kept).

Also corrects the comments that called the non-forced
GetAuthenticationStateAsync cheap: on CookieAuthenticationStateProvider both
entry points revalidate against the database and differ only in failClosed, so
the saving is not asking the provider on a tick and reusing the cascaded state
the router already resolved. The provider fallback is a real refresh, is only
taken when no cascade is present (AddCascadingAuthenticationState is
registered, so it is dead in production), and is now resolved at most once per
auth state rather than on every parameter set.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:29:36 -04:00
Joseph Doherty 623c6d0fe8 fix(rig): verify-pending-updates.sh cannot pass on an empty site list; numeric-safe comparison; earlier cleanup trap
The gate could pass vacuously. Every site-list read went through a bare
`ROWS=$(site_rows)` with neither the exit status nor the row count checked, and
an empty result satisfied every downstream assertion for free: no rows means no
malformed hash for B1/B2 to report, and a diff of two empty strings is no
difference at all for C1/D2. A down management API would therefore have read as
a clean gate.

Reads now go through read_rows, which fails loudly on a non-zero CLI exit
(site_rows exits 2 on an empty list, and pipefail surfaces a failed call) and
aborts unless the line count equals the site count established in step A —
keeping the running tally so the abort still prints PASS/FAIL. Verified against
an unreachable URL: FAIL 1, exit 1.

The join(1)/cut/diff comparisons assumed lexicographic id order matches numeric
order, which breaks once a site id reaches 10 alongside single-digit ids (and BSD
join silently drops the unmatched rows rather than complaining). Replaced with a
python3 comparison keyed by integer id that also asserts the id SETS match, so a
site appearing or vanishing between two reads invalidates the comparison instead
of quietly shrinking it. Same for the summary table.

Also: set -euo pipefail like every sibling script (the cmd && pass || fail guards
keep -e safe, and -e does not cover var=$(cmd) — hence the explicit checks
above); the cleanup trap is armed BEFORE the shared-script create and falls back
to finding the probe by its unique name, so a create that succeeds server-side
but whose id we fail to parse can no longer orphan a probe; mktemp instead of
/tmp/pu-deploy.$$.

PendingUpdatesTests: the rail-scroll check asserted scrollHeight >= clientHeight,
which holds for every element. It now asserts what the pane is actually for —
the pane is bounded by the viewport and the rail footer holding the indicator
stays on screen, so a nav that grows the rail instead of scrolling fails. Live
values are genuinely overflowing (scrollHeight 1196 vs clientHeight 680, footer
bottom 765 of 900), so the assertion has real content.

Re-run against the rig: PASS 13 / FAIL 0; Playwright 3/3.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:20:15 -04:00
Joseph Doherty af9f14a668 fix(central-ui): pending-updates indicator resolves roles/scope once per auth state, not per poll
SiteScopeService's instance methods resolve the AUTHORITATIVE state — a
fail-closed, DB-backed group-to-role re-mapping. The indicator called them on
every 30s tick, and it lives in MainLayout, so that was one or two forced
session revalidations per open circuit per page, forever: exactly the
per-circuit cost IPendingUpdatesCache exists to avoid.

Extracts the pure rule as SiteScopeService.Resolve(ClaimsPrincipal) (no I/O;
ResolveAsync now delegates to it, no behaviour change for existing callers).
The indicator takes the cascaded AuthenticationState — falling back to a
NON-forced GetAuthenticationStateAsync, as Templates/FlowDebugger do — and
computes roles plus scope once per auth state, re-running on
AuthenticationStateChanged so a mid-session revocation hides the block
instead of leaving it stale for the life of the circuit. Also routes the
first load through PollGate, adds aria-hidden to the icons, and comments the
off-renderer field writes and the InvalidOperationException catch.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:15:49 -04:00
Joseph Doherty 6dc784bca4 docs(cli): site list omits the artifact-revision fields until the first deploy (not null)
Found by docker/verify-pending-updates.sh step A: the management API
serializer uses WhenWritingNull, so a never-deployed site has no such keys.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:13:01 -04:00
Joseph Doherty a672f91d90 test(rig): verify-pending-updates.sh + Playwright smoke for the indicator and Sites badge
Rig rebuilt from this branch (image built at d8979664; migration
20260914173147_AddSiteArtifactRevision applied by central-b on boot).

verify-pending-updates.sh drives the CLI end to end: the site list carries the
artifact-revision fields, a fleet-wide deploy stamps every site with a
sha256:<64 hex> hash and a fresh timestamp, creating a shared script leaves the
stamp alone until the next deploy and then moves EVERY site's hash, and deleting
it again returns every hash byte-for-byte to its pre-create value — so the hash
is content-addressed, not deploy-addressed. PASS 13 / FAIL 0.

Step A asserts the SHAPE rather than key presence: the management API serializer
omits null properties, so on a never-deployed site the two keys are absent, not
present-and-null. Step E is a soft reachability check only; the badge text and
the rail indicator land after the Blazor circuit's post-render load.

PendingUpdatesTests covers that browser half — the rail footer settles off
"Checking for updates…", the Sites page carries an Artifacts badge per site card
plus a working Re-check control, and the rail nav's scroll pane computes
overflow-y: auto at lg+. 3/3 passed against the rig (rail read "Up to date",
3 badges over 3 cards, all "Artifacts current").

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:10:24 -04:00
Joseph Doherty 94e1d71ba2 fix(central-ui): Sites page re-check no longer holds the deploy controls; pluralise; clear stale caption on error
The forced refresh after a successful Deploy Artifacts ran inside the try, so
the Bulk-actions toggle and every per-site Deploy item stayed disabled for a
whole fleet evaluation after the success toast; it now runs after the finally,
behind a re-render, with the Re-check spinner showing it in flight. Also:
pluralise the instance line's noun, drop a stale "as of" caption when a
re-check faults, skip the fleet evaluation when the site load itself failed,
and render the badge tooltip through UtcDateTime so the UTC label is honest.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:08:52 -04:00
Joseph Doherty d8979664f1 docs: site artifact revision, pending-updates indicator, rail scroll pane
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:01:07 -04:00
Joseph Doherty c724ed6ee2 feat(central-ui): rail-footer pending-updates indicator
Adds PendingUpdatesIndicator to MainLayout's RailFooter, above .rail-user.
Self-gating on the Deployer/Administrator role claims, it polls the
process-wide single-flight IPendingUpdatesCache every 30s through PollGate
and renders four states: checking / up to date / pending (warning pill plus
an 'N instances · M sites' breakdown) / unavailable. Each half of the
breakdown links only for the role that can act on it (Topology => Deployer,
Sites => Administrator), and a site-scoped operator's totals are narrowed
through SiteScopeService so they never count a site they cannot see.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:00:16 -04:00
Joseph Doherty 5d6bd03868 feat(central-ui): Sites page shows whether a site's deployed artifacts are current
Each site card carries an Artifacts badge (current / out of date / never
deployed) plus an "N of M instances out of date" line linking to Topology,
both read from the shared pending-updates snapshot. The snapshot loads after
the site list so a slow fleet evaluation never holds the cards, and a failure
degrades the badge to "unavailable" instead of taking the page down. Header
gains a Re-check button and an as-of caption; a successful Deploy Artifacts
forces a refresh so the badge flips immediately.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:59:54 -04:00
Joseph Doherty 3d22f272d3 refactor(deploy): evaluator groups instances by site once; doc corrections
Tallying filtered the whole deployed-instance set inside the site loop, making the
aggregate O(sites x instances) — the one per-entity loop in an otherwise batched
pass. Group once into a lookup and index per site. Also: ResolveArtifactState's
missing-hash branch is a defensive default, not the race the comment described
(the same site list feeds the hash computation), and the staleness batch omits an
instance undeployed mid-batch as well as one that never had a snapshot.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:55:26 -04:00
Joseph Doherty ada91bb82c feat(central-ui): process-wide single-flight cache for the pending-updates snapshot
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:52:54 -04:00
Joseph Doherty 0287f8f64f fix(deploy): persist the artifact deployment record before the best-effort site stamp
Both repositories are scoped over one DbContext, so a failed stamp save left the
Site entries tracked as Modified and the deployment record's (uncaught)
SaveChangesAsync rethrew it — losing the audit row and failing a deploy in which
every site succeeded. Ordering rule now: audit row first, stamp after, inside its
own try/catch. Same in RetryForSiteAsync, where the site load is caught too so
nothing on the stamp path can reach the method's catch.

Also: the method-definitions canonicaliser matches the name property
case-insensitively (a naming-policy change at the producer would otherwise
silently turn the sort into a no-op), and ComputeCurrentArtifactHashesAsync
documents that a per-site query failure aborts the whole batch by design.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:51:34 -04:00
Joseph Doherty fc48db480c feat(deploy): PendingUpdatesEvaluator — one fleet snapshot of stale instances and stale-artifact sites
One scoped read that answers "is anything waiting to be deployed?" for the whole
fleet: each site's stored artifact revision hash compared against what a deploy
would send right now (NeverDeployed / Current / OutOfDate), plus per-site
deployed/stale/unknown instance tallies from the batched staleness evaluation.
NeverDeployed is deliberately NOT a pending update — the operator has not deployed
yet — and exceptions propagate so the caller's single-flight cache can render
"unavailable" rather than invent an "up to date". Adds the validated
PendingUpdatesCacheTtl (60 s) that TTL will key off.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:49:54 -04:00
Joseph Doherty 3b55d60ef9 fix(deploy): staleness batch logs one Warning per batch, not one per instance
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:48:06 -04:00