410c2e2925
Ran the 1A live gate on the docker-dev rig at fbf7b708 (image rebuilt from
this worktree, all eight containers recreated). VERDICT: FAIL.
Both central nodes fail to boot on this branch with
MissingActorRegistryEntryException: AuditWriterActorKey — the first WithActors
starter eagerly resolves IMeshCommandDialer/IMeshPskProvider, whose graph
reaches ActorAuditWriter, whose ctor reads an ActorRegistry key registered ~60
lines later in the same builder. Reproduced on the same image with
MeshTransport__Mode=ClusterClient, so it is mode-independent and not caused by
the gate's rig-only flip. Recorded as defect 4; no product change made.
Checks 5 (auth negative/positive on real Kestrel h2c) and 6 (wrong-key
isolation, both directions, with a positive control) PASS. Checks 3 and 4 pass
their node-side leg only, driven with real RestartDriver/ReconnectDriver/
AlarmCommand envelopes that restarted and reconnected SITEA-modbus. Checks 1,
2 and 7 are blocked outright — no central, no dispatch, no contact refresh —
and check 8 is vacuous for telemetry + config-serve because central dials both
and never booted. All five gaps are named in the carry-forward register and
must be closed by a 1A gate re-run, not carried past the phase.
Issues filed by this task: lmxopcua#544 (ConfigPublishCoordinatorTimeoutTests
deflake) and lmxopcua#545 (mesh planes are h2c-only — accept the risk or add a
UseTls option); the tracking table's two placeholder rows now carry them.
Also recorded: DriverStatusPanel.razor is referenced by no page or component,
so the AdminUI Reconnect/Restart controls are unreachable on this branch and on
master — pre-existing, out of program scope, carried forward.
Rig restored: compose flip reverted, six nodes healthy on all-ClusterClient.
The rig runs the PRE-gate image (the 1A build is kept as otopcua-host:grpc-1a);
a re-run must rebuild.
442 lines
31 KiB
Markdown
442 lines
31 KiB
Markdown
# ClusterClient → gRPC migration — live gate record
|
||
|
||
Rig: `docker-dev/` (central-1/-2 = `admin,driver,cluster-MAIN`; site-a-1/-2, site-b-1/-2;
|
||
sql + traefik + migrator + cluster-seed), rebuilt from the branch under test via
|
||
`docker compose -f docker-dev/docker-compose.yml up -d --build`. Recorded check-by-check in
|
||
the family's live-gate format. Later phases' sections are appended as those phases land; every
|
||
check row gets a **PASS**/**FAIL** verdict plus one quantitative justification clause, with
|
||
fenced evidence (real commands, real output). A run invalidated by a concurrent rig rebuild is
|
||
**void** — note it and re-run (the ScadaBridge void-run rule).
|
||
|
||
Provenance discipline: every phase section opens with branch + commit SHA + "image rebuilt,
|
||
all N containers recreated", and Phase 4 additionally verifies the image build timestamp
|
||
post-dates the deletion merge (a stale image invalidates the boundary checks).
|
||
|
||
Plan: `2026-08-06-clusterclient-to-grpc.md`. Tracking:
|
||
`2026-08-06-clusterclient-to-grpc-tracking.md`.
|
||
|
||
---
|
||
|
||
## Baseline (run on the PRE-change build, before the Phase 2 flip)
|
||
|
||
The two gaps this program closes, **reproduced rather than assumed**:
|
||
|
||
**(a) Unauthenticated Akka remoting crosses the WAN boundary.** From the host:
|
||
|
||
```bash
|
||
# A live TCP association from a site container to central's Akka port:
|
||
docker exec otopcua-dev-site-a-1-1 bash -c 'exec 3<>/dev/tcp/central-1/4053 && echo OPEN'
|
||
# And the receptionist actually accepting cross-mesh traffic (central log after any AdminUI
|
||
# driver restart targeting site-a):
|
||
docker logs otopcua-dev-central-1-1 2>&1 | grep -i "clusterclient\|receptionist" | tail -5
|
||
```
|
||
|
||
Expected: `OPEN`; receptionist/client lines present. Nothing on this path presents any
|
||
credential.
|
||
|
||
**(b) Silent drop on an unreachable cluster.** Stop the site-b pair, dispatch a deployment
|
||
(`POST http://localhost:9200/api/deployments`, `X-Api-Key: docker-dev-deploy-key`), observe:
|
||
no per-node dispatch error surfaces anywhere; site-b's `NodeDeploymentState` rows sit
|
||
`Applying` until the 2-minute deadline marks the deployment `TimedOut`/partially failed. The
|
||
deadline is the only witness. Restart the pair.
|
||
|
||
- [ ] Baseline (a) recorded with evidence — **PENDING**
|
||
- [ ] Baseline (b) recorded with evidence — **PENDING**
|
||
|
||
---
|
||
|
||
## Phase 0 — contract + auth + config groundwork — **PASS**
|
||
|
||
Branch `feat/grpc-phase0-contract-auth` @ `c2bfad4a`. Image rebuilt, all six containers
|
||
recreated (`docker compose -f docker-dev/docker-compose.yml up -d --build`, run from the
|
||
worktree at `/Users/dohertj2/Desktop/OtOpcUa/.worktrees/grpc-phase0` so the image carries this
|
||
branch). The rig was already running (from the main checkout's compose file) before this run;
|
||
the rebuild recreated all eight containers (sql kept, migrator + cluster-seed one-shots re-ran,
|
||
central-1/-2 + site-a-1/-2 + site-b-1/-2 + traefik recreated) under the fixed `otopcua-dev`
|
||
project name, so this run is not a concurrent-rebuild-invalidated one — nothing else touched
|
||
the stack afterwards.
|
||
|
||
| # | Check | Result |
|
||
|---|---|---|
|
||
| 1 | All six nodes boot on `ClusterClient` with the dark `Grpc` config present (PSKs, endpoints, ClusterPsks map); no validator fires | **PASS** — 6/6 nodes `restarts=0`, `docker inspect -f '{{.State.Status}} restarts={{.RestartCount}}'` reports `running restarts=0` on every node; `docker logs <node> | grep -iE "OptionsValidationException\|MeshTransport.*(is empty\|refuses to start)"` = 0 lines on all six |
|
||
| 2 | Deliberate misconfig: a scratch container with `MeshTransport__Mode=Grpc` and **no** `GrpcPsk` refuses to boot, error names `MeshTransport:GrpcPsk` + the env form | **PASS** — `Hosting failed to start` + `OptionsValidationException` naming `MeshTransport:GrpcPsk` and `MeshTransport__GrpcPsk`, below |
|
||
| 3 | Same with empty `CentralGrpcEndpoints` — refuses, names the key | **PASS** — `OptionsValidationException` naming `MeshTransport:CentralGrpcEndpoints` and `MeshTransport__CentralGrpcEndpoints__0`, below |
|
||
| 4 | Full suite green, 0 warnings, on the branch | **PASS** — `dotnet test ZB.MOM.WW.OtOpcUa.slnx --nologo`: 0 failures on a clean run (see evidence below); no `.cs` files changed on this branch since the last full rebuild, so the pre-existing ~868-warning baseline is unchanged (nothing to newly warn about) |
|
||
|
||
Note: the new gRPC services are not yet mapped in Phase 0, so no wire probes here — the
|
||
interceptor negative/positive matrix is unit-tested and wire-probed in 1A/1B.
|
||
|
||
**Check 1 evidence** — no validator fired on any of the six nodes, all dark `Grpc` keys present:
|
||
|
||
```
|
||
$ for c in central-1 central-2 site-a-1 site-a-2 site-b-1 site-b-2; do
|
||
docker inspect -f '{{.State.Status}} restarts={{.RestartCount}}' otopcua-dev-$c-1
|
||
done
|
||
running restarts=0 # central-1
|
||
running restarts=0 # central-2
|
||
running restarts=0 # site-a-1
|
||
running restarts=0 # site-a-2
|
||
running restarts=0 # site-b-1
|
||
running restarts=0 # site-b-2
|
||
|
||
$ docker logs otopcua-dev-central-1-1 2>&1 | grep -iE "MeshTransport|ClusterClient"
|
||
[08:53:26 INF] Mesh ClusterClient for cluster MAIN created with 2 contact point(s)
|
||
[08:53:26 INF] Mesh ClusterClient for cluster SITE-A created with 2 contact point(s)
|
||
[08:53:26 INF] Mesh ClusterClient for cluster SITE-B created with 2 contact point(s)
|
||
|
||
$ docker logs otopcua-dev-site-a-1-1 2>&1 | grep -iE "cluster.client|receptionist"
|
||
[08:53:26 INF] Connected to [akka.tcp://otopcua@central-2:4053/system/receptionist]
|
||
|
||
$ docker inspect otopcua-dev-site-a-1-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep MeshTransport
|
||
MeshTransport__CentralGrpcEndpoints__1=http://central-2:4055
|
||
MeshTransport__CentralGrpcEndpoints__0=http://central-1:4055
|
||
MeshTransport__Mode=ClusterClient
|
||
MeshTransport__GrpcPsk=dev-mesh-psk-SITE-A
|
||
MeshTransport__CentralContactPoints__1=akka.tcp://otopcua@central-2:4053
|
||
MeshTransport__CentralContactPoints__0=akka.tcp://otopcua@central-1:4053
|
||
|
||
$ curl -sf http://localhost:9200/health/ready
|
||
{"status":"Healthy", ..., "akka":{"status":"Healthy","description":"Akka cluster member status: Up",
|
||
"data":{"selfAddress":"akka.tcp://otopcua@central-2:4053","selfRoles":["admin","cluster-MAIN","driver"],
|
||
"memberCount":2,"unreachableCount":0,"leader":"akka.tcp://otopcua@central-1:4053"}}}
|
||
```
|
||
|
||
**Check 2 evidence** — scratch container from `otopcua-host:dev`, site-a-1's real environment
|
||
with `MeshTransport__GrpcPsk` removed and `Mode` forced to `Grpc`:
|
||
|
||
```
|
||
$ docker run --rm --name scratch-check2-noPsk --network otopcua-dev_default \
|
||
--env-file check2-env.txt otopcua-host:dev
|
||
[08:54:23 ERR] Hosting failed to start
|
||
Microsoft.Extensions.Options.OptionsValidationException: MeshTransport:GrpcPsk is empty. Under
|
||
Grpc this per-cluster pre-shared key is the whole authentication boundary of the command plane
|
||
in both directions — without it this node's inbound command service fails closed and every ack
|
||
it posts to central is rejected. Supply it via the environment (MeshTransport__GrpcPsk), in
|
||
production as ${secret:OT-MESH-PSK-<clusterId>}.
|
||
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
|
||
...
|
||
```
|
||
|
||
**Check 3 evidence** — same base env, `GrpcPsk` present, both `CentralGrpcEndpoints` indices
|
||
unset:
|
||
|
||
```
|
||
$ docker run -d --name scratch-check3-noEndpoints --network otopcua-dev_default \
|
||
--env-file check3-env.txt otopcua-host:dev
|
||
$ docker logs scratch-check3-noEndpoints 2>&1 | grep -A2 "OptionsValidationException"
|
||
[08:55:55 ERR] Hosting failed to start
|
||
Microsoft.Extensions.Options.OptionsValidationException: MeshTransport:CentralGrpcEndpoints is
|
||
empty. Under Grpc a driver node has nowhere to post its deployment ack, and every deployment
|
||
would time out naming a node that is running perfectly. Supply them via the environment
|
||
(MeshTransport__CentralGrpcEndpoints__0).
|
||
```
|
||
|
||
Both scratch containers removed after capture (`docker rm -f scratch-check2-noPsk
|
||
scratch-check3-noEndpoints`); the rig's six real nodes were never affected (still `restarts=0`
|
||
after both runs).
|
||
|
||
**Check 4 evidence** — full suite, clean re-run (a first run hit one race-condition flake in
|
||
`RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed`,
|
||
which the test's own name identifies as exercising a race; re-ran in isolation and it passed,
|
||
then a full second run passed clean end to end — pre-existing timing sensitivity, not a
|
||
regression from this branch, which touched only `docker-dev/docker-compose.yml` and
|
||
`docs/Configuration.md`, no `.cs` files):
|
||
|
||
```
|
||
$ dotnet test ZB.MOM.WW.OtOpcUa.slnx --nologo
|
||
...
|
||
Passed! - Failed: 0, Passed: 212, Skipped: 4, Total: 216, Duration: 3m56s - ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.dll (net10.0)
|
||
$ echo $?
|
||
0
|
||
```
|
||
|
||
Rig left running on `ClusterClient` (its normal state) after the gate.
|
||
|
||
---
|
||
|
||
## Phase 1A — central→node command plane — **FAIL (blocked at the central boot boundary)**
|
||
|
||
Branch `feat/grpc-node-command` @ `fbf7b708`. Image rebuilt from this worktree
|
||
(`/Users/dohertj2/Desktop/OtOpcUa/.worktrees/grpc-1a`) so it carries this branch;
|
||
`docker compose -f docker-dev/docker-compose.yml up -d --build` exited 0 and **all eight
|
||
containers were recreated** — `migrator` and `cluster-seed` re-ran to completion (exit 0), the
|
||
six host nodes and traefik were recreated, the `sql` container and its named volume were kept.
|
||
Nothing else touched the stack afterwards, so this run is not a concurrent-rebuild-invalidated
|
||
one. Rig-only flip: central-1/-2 `MeshTransport__Mode: "Grpc"` (hard value); **site nodes left
|
||
on the compose default `ClusterClient`** (coexistence is the point). Three Serilog
|
||
`MinimumLevel:Override` Debug scopes were added on all six nodes for this run only
|
||
(`ZB.MOM.WW.OtOpcUa.Host.Grpc`, `ZB.MOM.WW.OtOpcUa.ControlPlane.Communication`,
|
||
`Akka.Cluster.Tools.Client`) — the accept/dispatch lines the checks read are `Debug` and the
|
||
rig's default Serilog level is `Information`, so without them checks 1/3 would have had no
|
||
observable signal. Flip and overrides reverted after the gate, never committed.
|
||
|
||
**Verdict: FAIL.** Neither central node boots on this branch, so nothing that originates at
|
||
central could be driven. Root cause is defect 4 below — resolving `IMeshCommandDialer` /
|
||
`IMeshPskProvider` inside the first `WithActors` starter drags in `ISecretResolver` →
|
||
`IAuditWriter` → `ActorAuditWriter`, whose constructor reads `AuditWriterActorKey` out of the
|
||
`ActorRegistry` before the singleton that registers it has run. It is **mode-independent**
|
||
(reproduced on the same image with `MeshTransport__Mode=ClusterClient`), so it is a defect of
|
||
the branch, not of the gate's flip, and it would have failed a `ClusterClient` rig equally.
|
||
|
||
| # | Check | Result |
|
||
|---|---|---|
|
||
| 1 | Deploy dispatch from AdminUI reaches **all six** driver nodes over gRPC (per-node `Execute` log lines on every node; zero `SendToAll` lines on central) | **BLOCKED** — `POST /api/deployments` is unreachable: traefik answers `503` because neither central node has a live HTTP surface (`curl http://central-1:9000/health/ready` and `central-2` both return `000`, connection refused). Nothing dispatched. |
|
||
| 2 | Acks still return over ClusterClient (site mode unchanged) and the deployment **seals** — mixed-transport deploy proven end to end | **BLOCKED** — no deployment could be dispatched (check 1). The most recent `dbo.Deployment` row is still the pre-gate `03540DA5-…` (`Status=2`, Sealed, `2026-08-06 06:11:22`), i.e. nothing was attempted during this run. |
|
||
| 3 | `RestartDriver` + `ReconnectDriver` from AdminUI land on the target site node (EventStream delivery observed via driver restart in logs) | **PARTIAL PASS (node-side leg only)** — both commands were delivered to site-a-1 over real Kestrel h2c with the correct PSK and both acted: `DriverHost site-a-1:4053: restarting driver SITEA-modbus by request of 1a-gate` followed by a fresh `spawned Modbus driver SITEA-modbus`, and `reconnecting driver … ForceReconnect requested by admin; re-entering Reconnecting`. The **AdminUI half was not driven** — see the two carry-forwards below (central down; and the UI control is unreachable on this branch anyway). |
|
||
| 4 | `AlarmCommand` (ack a scripted alarm from AdminUI) lands and the condition acknowledges | **PARTIAL PASS (envelope leg only)** — the alarm arm decoded and was delivered node-side (`Mesh: accepted AlarmCommand (correlationId=39ba4511…) over gRPC`), which is the arm with no CLR correlation member and therefore the one worth wiring live. **The acknowledge itself was NOT observed**: the rig's single `dbo.ScriptedAlarm` row (`gate481-alarm`, equipment `EQ-6e09bcc2d907`) is not owned by SITE-A, and its owning node is a central one, which is down. |
|
||
| 5 | Unauthenticated `grpcurl` to a site node `:4056` `mesh_transport.v1.NodeCommandService/Execute` ⇒ `PermissionDenied`; correct dev PSK ⇒ accepted | **PASS** — 3/3 probes graded as designed: no bearer ⇒ `PermissionDenied`, correct `dev-mesh-psk-SITE-A` + empty envelope ⇒ `InvalidArgument` (**auth passed**, the decode is what failed), and a full `RestartDriver` envelope with the same key ⇒ `{}` + observed driver restart. |
|
||
| 6 | **Wrong-key isolation:** site-b's PSK presented to site-a ⇒ `PermissionDenied` (per-cluster scoping is real, not decorative) | **PASS** — both directions rejected: `dev-mesh-psk-SITE-B` → site-a-1 ⇒ `PermissionDenied`, `dev-mesh-psk-SITE-A` → site-b-1 ⇒ `PermissionDenied`, while `dev-mesh-psk-SITE-B` → site-b-1 reaches the decoder (`InvalidArgument`) — so the rejection is the key, not the reachability. |
|
||
| 7 | A node with NULL `GrpcPort` is skipped with the warn-once, fan continues to the others | **BLOCKED** — the skip is computed by `CentralCommunicationActor.AdoptGrpcTargets` on a contact refresh; no central node is running to refresh. The rig's `ClusterNode` rows were left untouched (all six still `GrpcPort=4056`) rather than mutated for a check that could not be observed. |
|
||
| 8 | Telemetry + config-serve + LocalDb sync unaffected: 0 auth failures on their interceptors across the run | **PARTIAL PASS** — 0 `Rejected a telemetry stream call` / `Rejected a config-serve call` / `Rejected a LocalDb sync call` lines across all six nodes, and the only `Rejected a mesh command call` lines are the 3 deliberate negative probes (2 on site-a-1, 1 on site-b-1). **But telemetry and config-serve carried no traffic at all** in this run — central dials both and never booted (site-a-1 logs 0 telemetry lines) — so "unaffected" is established for LocalDb sync only. |
|
||
|
||
### Phase 1A evidence
|
||
|
||
**Provenance** — flip in effect on central only, sites untouched:
|
||
|
||
```
|
||
$ for c in central-1 central-2 site-a-1 site-b-1; do docker inspect otopcua-dev-$c-1 \
|
||
--format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^MeshTransport__Mode='; done
|
||
central-1 MeshTransport__Mode=Grpc
|
||
central-2 MeshTransport__Mode=Grpc
|
||
site-a-1 MeshTransport__Mode=ClusterClient
|
||
site-b-1 MeshTransport__Mode=ClusterClient
|
||
|
||
$ docker images --format '{{.Repository}}:{{.Tag}}|{{.ID}}|{{.CreatedAt}}' | grep otopcua-host
|
||
otopcua-host:dev|69360a173f1f|2026-08-06 07:20:14 -0400 EDT # rebuilt for this run
|
||
```
|
||
|
||
**Defect 4 — the boot failure (checks 1/2/7 blocked by it).** Both central nodes, identical:
|
||
|
||
```
|
||
$ docker logs otopcua-dev-central-1-1 2>&1 | sed -n '25,45p'
|
||
[11:21:27 INF] OPC UA server started on opc.tcp://0.0.0.0:4840
|
||
[11:21:27 FTL] Unable to start AkkaHostedService - shutting down application
|
||
Akka.Hosting.MissingActorRegistryEntryException: No actor registered for key
|
||
ZB.MOM.WW.OtOpcUa.ControlPlane.AuditWriterActorKey
|
||
at Akka.Hosting.ActorRegistry.Get[TKey]()
|
||
at ZB.MOM.WW.OtOpcUa.AdminUI.Audit.ActorAuditWriter..ctor(ActorRegistry registry)
|
||
in /src/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/ActorAuditWriter.cs:line 24
|
||
...
|
||
at ZB.MOM.WW.Secrets.DependencyInjection.SecretsServiceCollectionExtensions.<>c.<AddZbSecrets>b__0_5(IServiceProvider sp)
|
||
...
|
||
at ZB.MOM.WW.Secrets.DependencyInjection.SecretsServiceCollectionExtensions.<>c.<AddZbSecrets>b__0_6(IServiceProvider sp)
|
||
...
|
||
at ZB.MOM.WW.OtOpcUa.ControlPlane.ServiceCollectionExtensions.<>c.<WithOtOpcUaControlPlaneSingletons>b__7_0(
|
||
ActorSystem system, IActorRegistry registry, IDependencyResolver resolver)
|
||
in /src/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs:line 74
|
||
at Akka.Hosting.AkkaConfigurationBuilder.StartAsync(ActorSystem sys)
|
||
at Akka.Hosting.AkkaHostedService.StartAsync(CancellationToken cancellationToken)
|
||
```
|
||
|
||
**Mode-independence, proven rather than argued** — same image, central-1's exact environment
|
||
with `MeshTransport__Mode=ClusterClient`, own hostname + self-seed so it cannot disturb the rig:
|
||
|
||
```
|
||
$ docker run -d --name scratch-cc-mode --network otopcua-dev_default --env-file c1-env.txt \
|
||
-e Cluster__PublicHostname=scratch-cc-mode \
|
||
-e Cluster__SeedNodes__0=akka.tcp://otopcua@scratch-cc-mode:4053 otopcua-host:dev
|
||
$ docker logs scratch-cc-mode 2>&1 | grep -m4 -E "MissingActorRegistry|Hosting failed|Host starting"
|
||
[11:28:45 INF] OtOpcUa.Host starting with roles=[admin,driver,cluster-main] (admin=True, driver=True)
|
||
Akka.Hosting.MissingActorRegistryEntryException: No actor registered for key
|
||
ZB.MOM.WW.OtOpcUa.ControlPlane.AuditWriterActorKey
|
||
[11:28:47 ERR] Hosting failed to start
|
||
```
|
||
|
||
Scratch container removed after capture (`docker rm -f scratch-cc-mode`); the six real nodes
|
||
were untouched by it.
|
||
|
||
**It presents as a healthy container.** Worth carrying into Phase 2/4 readings — the failed
|
||
process does not exit, so `docker ps` shows `Up`, `RestartCount=0`, and only an HTTP probe or
|
||
the log tells the truth:
|
||
|
||
```
|
||
$ docker inspect otopcua-dev-central-1-1 -f '{{.State.Status}} restarts={{.RestartCount}}'
|
||
running restarts=0
|
||
$ docker run --rm --network otopcua-dev_default curlimages/curl -s -o /dev/null \
|
||
-w '%{http_code}\n' --max-time 5 http://central-1:9000/health/ready
|
||
000
|
||
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:9200/health/ready
|
||
503
|
||
```
|
||
|
||
**Checks 5 + 6 — the wire-probe auth matrix** (`-import-path` onto the in-repo proto; the node
|
||
has no reflection). An empty `CommandEnvelope` has no `oneof` arm set, so `FromEnvelope` throws
|
||
and the service answers `InvalidArgument` — **after** the interceptor ran, which is exactly why
|
||
it is the positive signal here: `PermissionDenied` means the key was refused, `InvalidArgument`
|
||
means the key was accepted and only the payload was rejected:
|
||
|
||
```
|
||
$ P=src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos
|
||
$ G="docker run --rm --network otopcua-dev_default -v $PWD/$P:/protos:ro fullstorydev/grpcurl \
|
||
-plaintext -import-path /protos -proto mesh_transport.proto"
|
||
|
||
# 5a no bearer -> site-a-1
|
||
Code: PermissionDenied Message: Node command authentication failed.
|
||
# 5b -H 'authorization: Bearer dev-mesh-psk-SITE-A' -> site-a-1
|
||
Code: InvalidArgument Message: Command envelope could not be decoded.
|
||
# 6a -H 'authorization: Bearer dev-mesh-psk-SITE-B' -> site-a-1
|
||
Code: PermissionDenied Message: Node command authentication failed.
|
||
# 6b -H 'authorization: Bearer dev-mesh-psk-SITE-A' -> site-b-1
|
||
Code: PermissionDenied Message: Node command authentication failed.
|
||
# 6c -H 'authorization: Bearer dev-mesh-psk-SITE-B' -> site-b-1 (positive control)
|
||
Code: InvalidArgument Message: Command envelope could not be decoded.
|
||
```
|
||
|
||
**Checks 3 + 4 — the node-side leg, driven with real envelopes** over the same authenticated
|
||
h2c path central would use. This is the first live proof that `MeshCommandAuthInterceptor` is
|
||
actually invoked on a real Kestrel pipeline (register item 4) and that
|
||
`NodeCommandGrpcService` → `NodeCommunicationActor` → EventStream → `DriverHostActor` works
|
||
end to end:
|
||
|
||
```
|
||
$ $G -H 'authorization: Bearer dev-mesh-psk-SITE-A' -H 'x-otopcua-cluster: SITE-A' \
|
||
-d '{"correlationId":"8461008e...","restartDriver":{"clusterId":"SITE-A",
|
||
"driverInstanceId":"SITEA-modbus","actorByUserName":"1a-gate",
|
||
"correlationId":"8461008e..."}}' \
|
||
site-a-1:4056 mesh_transport.v1.NodeCommandService/Execute
|
||
{}
|
||
|
||
$ docker logs otopcua-dev-site-a-1-1 2>&1 | tail
|
||
[11:31:01 DBG] Mesh: accepted RestartDriver (correlationId=8461008e37654724a292219202d78dfa) over gRPC; handed to the node communication actor.
|
||
[11:31:01 INF] DriverHost site-a-1:4053: restarting driver SITEA-modbus by request of 1a-gate
|
||
[11:31:01 INF] DriverHost site-a-1:4053: spawned Modbus driver SITEA-modbus (stub=False)
|
||
[11:31:55 DBG] Mesh: accepted ReconnectDriver (correlationId=29c5dc77283f4eb7869d0c7d6686cadd) over gRPC; handed to the node communication actor.
|
||
[11:31:55 INF] DriverHost site-a-1:4053: reconnecting driver SITEA-modbus by request of 1a-gate
|
||
[11:31:55 INF] DriverInstance SITEA-modbus: ForceReconnect requested by admin; re-entering Reconnecting
|
||
[11:31:56 DBG] Mesh: accepted AlarmCommand (correlationId=39ba451173954229a71b9dd1e00ac1b9) over gRPC; handed to the node communication actor.
|
||
```
|
||
|
||
**Check 8 — interceptor denial counts across every node, whole run:**
|
||
|
||
```
|
||
$ for c in central-1 central-2 site-a-1 site-a-2 site-b-1 site-b-2; do … done
|
||
central-1 telemetry=0 configserve=0 localdbsync=0 mesh=0
|
||
central-2 telemetry=0 configserve=0 localdbsync=0 mesh=0
|
||
site-a-1 telemetry=0 configserve=0 localdbsync=0 mesh=2 # the two deliberate negatives
|
||
site-a-2 telemetry=0 configserve=0 localdbsync=0 mesh=0
|
||
site-b-1 telemetry=0 configserve=0 localdbsync=0 mesh=1 # the one deliberate negative
|
||
site-b-2 telemetry=0 configserve=0 localdbsync=0 mesh=0
|
||
```
|
||
|
||
The `TwoNodeClusterHarness` I-1 null-provider Error (a known test-harness artifact) did **not**
|
||
appear in any rig log — `grep -c "IMeshPskProvider"` = 0 on all six nodes, as expected for
|
||
production wiring where every admin node registers the provider.
|
||
|
||
### Phase 1A defect
|
||
|
||
**Defect 4 — an admin node cannot boot: eager DI resolution inside the first Akka starter.**
|
||
`WithOtOpcUaControlPlaneSingletons`'s `WithActors` block resolves `IMeshCommandDialer` and
|
||
`IMeshPskProvider` from `IServiceProvider` to hand to `CentralCommunicationActor.Props`.
|
||
`MeshGrpcCommandDialer` takes `IMeshPskProvider`, which takes `ISecretResolver`, which
|
||
`AddZbSecrets` builds by resolving `IAuditWriter`, which on an admin node is
|
||
`ActorAuditWriter` — and its constructor does `registry.Get<AuditWriterActorKey>()`. That
|
||
singleton is registered ~60 lines later in the same builder, so at this point the key is
|
||
absent and `ActorRegistry.Get` throws `MissingActorRegistryEntryException`, failing
|
||
`AkkaHostedService.StartAsync` and the whole host.
|
||
|
||
Three things make this worth a pinned regression rather than a one-line fix note:
|
||
|
||
- **It is not mode-gated.** The two `GetService` calls run in every transport mode by design
|
||
(so a mode flip needs no restart), so the eager graph is walked on every admin boot. Proven
|
||
above with a `ClusterClient` scratch container on the same image.
|
||
- **It is invisible to the suite.** The 1A.2 container-activation test
|
||
(`It_activates_from_the_container_despite_the_optional_client_factory_parameter`) resolves
|
||
the dialer from a service collection that does not include `AddZbSecrets`, and
|
||
`TwoNodeClusterHarness` registers no `IMeshPskProvider` at all — so no existing test walks
|
||
the real graph in starter order. This is the same class as scadaproj#1 ("only visible with
|
||
the DI graph built in a real host") and as mesh Phase 6's `IClusterRoleInfo` boot crash.
|
||
- **The container lies about it.** The process does not exit, so `docker ps` reports `Up` with
|
||
`restarts=0` while the node serves no HTTP at all. Any Phase 2/4 readiness check that reads
|
||
container status rather than `/health/ready` would score this as healthy.
|
||
|
||
The shape of the fix is the one this file already uses two lines above for the coordinator
|
||
proxy — hand the actor a lazy accessor (`Func<IMeshCommandDialer?>` / `Func<IMeshPskProvider?>`,
|
||
or resolve on first use) so nothing is constructed while the `ActorSystem` is still building —
|
||
but the fix and its red-first pin belong to 1A, not to this record.
|
||
|
||
### Rig state after the gate
|
||
|
||
The compose flip was reverted (`git checkout docker-dev/docker-compose.yml`) and the six nodes
|
||
recreated on the committed all-`ClusterClient` compose. Because defect 4 is mode-independent,
|
||
the branch image cannot produce a healthy central pair on *any* mode, so the rig was returned
|
||
to its pre-gate image rather than left with a dead central pair: the 1A build was preserved as
|
||
`otopcua-host:grpc-1a` (`69360a173f1f`) and the pre-gate build (`8bd81d8aea9b`, 04:52) re-tagged
|
||
back to `otopcua-host:dev`. **Anyone re-running this gate must rebuild — the running rig is
|
||
NOT on the branch under test.** Verified after restore: six nodes `running restarts=0`, zero
|
||
`Hosting failed to start` on all six, `/health/ready` 200 on every node, MAIN mesh
|
||
`memberCount=2 unreachableCount=0`, AdminUI 200 on `http://localhost:9200`.
|
||
|
||
---
|
||
|
||
## Phase 1B — node→central ack plane — **PENDING**
|
||
|
||
Branch `feat/grpc-central-ack` @ `________` (rebased on merged 1A). Rig-only flip:
|
||
site-a-1/-2 → `Grpc`; site-b stays `ClusterClient`; central back on `ClusterClient` mode
|
||
(its command direction) — acks are governed by the **node's** mode, so this isolates 1B.
|
||
|
||
| # | Check | Result |
|
||
|---|---|---|
|
||
| 1 | Deploy e2e: site-a acks arrive via `ReportApply` (central `CentralCommandService` log lines carry site-a node ids), `NodeDeploymentState` rows land, deployment seals | PENDING |
|
||
| 2 | Site-b acks still arrive via ClusterClient in the same deployment — per-pair coexistence proven | PENDING |
|
||
| 3 | Exactly **one** ack row per site-a node per dispatch (no double-count from endpoint failover) | PENDING |
|
||
| 4 | Kill central-1 before the apply completes: site-a acks fail over to central-2's endpoint; deployment still seals | PENDING |
|
||
| 5 | Unauthenticated / wrong-cluster-key / missing `x-otopcua-cluster` probes against central `:4055` `CentralCommandService/ReportApply` ⇒ `PermissionDenied` (missing-header rejection is central-side by design) | PENDING |
|
||
| 6 | ConfigServe (`DeploymentArtifactService`) on the same listener unaffected: fetches succeed, 0 auth failures | PENDING |
|
||
|
||
---
|
||
|
||
## Phase 2 — full-rig cutover + soak — **PENDING**
|
||
|
||
Rig default flipped and committed: `MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"`.
|
||
Branch/SHA `________`. **Run the Baseline section first on the pre-flip build.**
|
||
|
||
| # | Check | Result |
|
||
|---|---|---|
|
||
| 1 | Full rebuild all-`Grpc`: six nodes ready, deploy e2e seals, all acks via gRPC | PENDING |
|
||
| 2 | Command matrix: Restart/Reconnect/AlarmCommand against site-a and site-b nodes | PENDING |
|
||
| 3 | Kill site-a-1 mid-dispatch: per-node `Execute` failure **logged with node id + status** (the drop is visible now); no retry storm (exactly one attempt per node per dispatch); deployment outcome honest (deadline path) | PENDING |
|
||
| 4 | Kill central-1: site acks fail over to central-2; restart central-1: rig converges, no manual action | PENDING |
|
||
| 5 | Stop the site-b pair entirely, dispatch: per-node dispatch failures logged immediately (contrast with Baseline (b)'s silence); deadline backstop still marks the deployment; **no duplicate applies** after the pair returns (boot-replay path only) | PENDING |
|
||
| 6 | **Zero ClusterClient activity under `Grpc`:** `docker logs <every node> \| grep -ci "clusterclient"` = 0 new lines post-boot | PENDING |
|
||
| 7 | Soak ≥ 2 h under periodic deploys + command traffic: 0 `PermissionDenied`/`Unauthenticated` across all six nodes (PSK drift detector), 0 unexplained `Unavailable` | PENDING |
|
||
| 8 | Full suite green on the cutover SHA | PENDING |
|
||
|
||
---
|
||
|
||
## Phase 4 — terminal gate on the post-deletion build — **PENDING**
|
||
|
||
`master` @ `________` (after the Phase 3 deletion merge). **Provenance guard:** image build
|
||
timestamp verified LATER than the merge commit time — a pre-deletion image would invalidate
|
||
checks 6–7.
|
||
|
||
| # | Check | Result |
|
||
|---|---|---|
|
||
| 1 | PSK negatives on both services (no key / wrong key / wrong cluster's key); **unset server key ⇒ all calls rejected** (fail-closed proven live) | PENDING |
|
||
| 2 | Command matrix all six nodes (deploy dispatch + Restart + Reconnect + AlarmCommand) | PENDING |
|
||
| 3 | Ack matrix: every node acks exactly once, deployment seals | PENDING |
|
||
| 4 | Failover drills: central-kill ack failover; site-node-kill dispatch honesty (one logged failure, no retry) | PENDING |
|
||
| 5 | Mid-deploy central kill + restart: deadline/boot-replay backstops hold, ack counts exact (no loss, no dupes across the restart) | PENDING |
|
||
| 6 | **Boundary check: no Akka association between any site container and central** — the Baseline (a) probe now shows no cross-mesh `:4053` traffic (pair-internal remoting only) | PENDING |
|
||
| 7 | Full-rig restart (pairs together): clean convergence; `grep -ri "receptionist\|clusterclient"` over all node logs = 0 lines | PENDING |
|
||
| 8 | `MeshGrpcBoundaryTests` + full suite green on the merge SHA | PENDING |
|
||
|
||
### Carry-forwards
|
||
|
||
Anything a phase gate could not drive gets named here with why + what proves it instead, and
|
||
is re-checked in the next gate (the ScadaBridge proportionate-gate rule).
|
||
|
||
| Item | Deferred from | Why it could not be driven | Proven instead by | Re-check in |
|
||
|---|---|---|---|---|
|
||
| Checks 1, 2, 7 in full — deploy dispatch over gRPC, mixed-transport seal, NULL-`GrpcPort` skip | 1A | Both central nodes fail to boot (defect 4); no AdminUI, no deploy API, no contact refresh | Nothing live. Unit coverage only: the fan Theory, the gate-⊋-fan fact, `A_slow_dial_does_not_park_the_actors_mailbox` | **1A gate re-run after defect 4 is fixed** — these are the checks 1A exists to prove and must not be carried past it |
|
||
| Check 3 central half — `RestartDriver`/`ReconnectDriver` **originating at the AdminUI** | 1A | (a) central down; (b) independently, `DriverStatusPanel.razor` — the only component holding the Reconnect/Restart buttons — is referenced by **no page or component in the AdminUI**, so the control is unreachable in the running UI on this branch and on `master` | The node-side leg driven live with real envelopes (check 3 evidence); central's encode covered by the T0.2 mapper goldens | 1A re-run; the orphaned panel is pre-existing and out of this program's scope — file separately if it is to be fixed |
|
||
| Check 4 acknowledge half — the condition actually acknowledging | 1A | The rig's one `ScriptedAlarm` (`gate481-alarm`) is not owned by a SITE cluster, and its owning central node is down | The alarm arm decoding + delivering node-side (check 4 evidence) | 1A re-run, seeding a SITE-A-owned scripted alarm first |
|
||
| Check 8 for telemetry + config-serve | 1A | Central dials both planes and never booted, so neither carried a single call — 0 denials is vacuous for them | LocalDb sync (pair-local) did carry traffic with 0 denials | 1A re-run |
|
||
|
||
### Defects the gates caught that the suite did not
|
||
|
||
Recorded per phase as they occur, with root cause + the pinned regression test.
|
||
|
||
| # | Phase | Defect | Root cause | Status |
|
||
|---|---|---|---|---|
|
||
| 4 | 1A gate | **An admin node cannot boot at all** — `MissingActorRegistryEntryException: AuditWriterActorKey`, both central nodes, every transport mode | The first `WithActors` starter eagerly resolves `IMeshCommandDialer`/`IMeshPskProvider`, whose graph reaches `ActorAuditWriter`, whose ctor reads an `ActorRegistry` key registered ~60 lines later in the same builder | **OPEN — blocks 1A.** Full analysis + evidence in the Phase 1A section above. No product change was made by this gate |
|