merge: ClusterClient→gRPC Phase 0 — mesh_transport.v1 contract, DTO mapper + wire goldens, both PSK auth interceptors + per-cluster provider, Grpc mode + validators, rig dark config (gate PASS 4/4; #542/#543 filed)
This commit is contained in:
@@ -348,6 +348,20 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
# gRPC command transport (Phase 0 of the ClusterClient→gRPC migration, docs/plans/2026-08-06-
|
||||
# clusterclient-to-grpc-design.md). SHIPPED DARK: Mode above stays ClusterClient, so none of
|
||||
# this is dialled or served yet — only MeshTransportOptionsValidator's Grpc-mode rules read
|
||||
# it. GrpcPsk is this node's OWN per-cluster key (MAIN, since central is cluster-MAIN);
|
||||
# CentralGrpcEndpoints is the gRPC counterpart of CentralContactPoints above. ClusterPsks is
|
||||
# CENTRAL-SIDE ONLY (IMeshPskProvider's override map, consulted before the Secrets store) —
|
||||
# one entry per cluster central will dial once the switch flips, so only the central nodes
|
||||
# carry it.
|
||||
MeshTransport__GrpcPsk: "dev-mesh-psk-MAIN"
|
||||
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
|
||||
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
|
||||
MeshTransport__ClusterPsks__MAIN: "dev-mesh-psk-MAIN"
|
||||
MeshTransport__ClusterPsks__SITE-A: "dev-mesh-psk-SITE-A"
|
||||
MeshTransport__ClusterPsks__SITE-B: "dev-mesh-psk-SITE-B"
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
@@ -473,6 +487,14 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
# gRPC command transport (Phase 0) — see central-1. SHIPPED DARK (Mode stays ClusterClient).
|
||||
# ClusterPsks is central-side only; both central nodes carry the same three-cluster map.
|
||||
MeshTransport__GrpcPsk: "dev-mesh-psk-MAIN"
|
||||
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
|
||||
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
|
||||
MeshTransport__ClusterPsks__MAIN: "dev-mesh-psk-MAIN"
|
||||
MeshTransport__ClusterPsks__SITE-A: "dev-mesh-psk-SITE-A"
|
||||
MeshTransport__ClusterPsks__SITE-B: "dev-mesh-psk-SITE-B"
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
@@ -594,6 +616,13 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
# gRPC command transport (Phase 0) — see central-1. SHIPPED DARK (Mode stays ClusterClient).
|
||||
# GrpcPsk is this node's own key for its cluster (SITE-A); CentralGrpcEndpoints is the gRPC
|
||||
# counterpart of CentralContactPoints above. No ClusterPsks here — that map is central-side
|
||||
# only.
|
||||
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-A"
|
||||
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
|
||||
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
|
||||
<<: [*secrets-env, *ldap-env, *apikeys-env]
|
||||
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
|
||||
# mem_reservation are inherited from the *otopcua-host anchor.
|
||||
@@ -701,6 +730,11 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
# gRPC command transport (Phase 0) — see central-1/site-a-1. SHIPPED DARK. Same SITE-A key as
|
||||
# site-a-1 (must be byte-identical — both nodes authenticate against the same cluster's PSK).
|
||||
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-A"
|
||||
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
|
||||
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
|
||||
<<: [*secrets-env, *ldap-env, *apikeys-env]
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
@@ -789,6 +823,12 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
# gRPC command transport (Phase 0) — see central-1. SHIPPED DARK. GrpcPsk is this node's own
|
||||
# key for its cluster (SITE-B); CentralGrpcEndpoints mirrors CentralContactPoints above. No
|
||||
# ClusterPsks here — that map is central-side only.
|
||||
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-B"
|
||||
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
|
||||
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
|
||||
<<: [*secrets-env, *ldap-env, *apikeys-env]
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
@@ -861,6 +901,11 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
# gRPC command transport (Phase 0) — see central-1/site-b-1. SHIPPED DARK. Same SITE-B key as
|
||||
# site-b-1 (must be byte-identical).
|
||||
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-B"
|
||||
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
|
||||
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
|
||||
<<: [*secrets-env, *ldap-env, *apikeys-env]
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
|
||||
+16
-2
@@ -165,8 +165,8 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `Mode` | string | `Dps` | `Dps` = DistributedPubSub (the transport this repo has always used). `ClusterClient` = the mesh boundary. Any other value **refuses to start**. |
|
||||
| `CentralContactPoints` | string[] | `[]` | Node-side only: central node **addresses** (`akka.tcp://<system>@<host>:<port>`). Required under `ClusterClient`, ignored under `Dps`. |
|
||||
| `Mode` | string | `Dps` | `Dps` = DistributedPubSub (the transport this repo has always used). `ClusterClient` = the mesh boundary. `Grpc` = the `mesh_transport.v1` gRPC contract — **shipped dark, cutover pending** (see below). Any other value **refuses to start**. |
|
||||
| `CentralContactPoints` | string[] | `[]` | Node-side only: central node **addresses** (`akka.tcp://<system>@<host>:<port>`). Required under `ClusterClient`, ignored under `Dps`/`Grpc`. |
|
||||
| `ContactRefreshSeconds` | int | `60` | Central-side only: how often central rebuilds its ClusterClient contact set from the `ClusterNode` rows. |
|
||||
|
||||
**Addresses only — no actor-path suffix.** `/system/receptionist` is appended at construction time; a contact carrying `/user/` or `/system/` is rejected at startup, because a doubled suffix produces a path that resolves to nothing and drops every command in silence.
|
||||
@@ -181,6 +181,20 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
|
||||
|
||||
On the docker-dev rig every node carries these keys already; flip the whole rig with `OTOPCUA_MESH_MODE=ClusterClient` at `docker compose up`.
|
||||
|
||||
**`Grpc` mode — shipped dark, cutover pending** ([ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-to-grpc-design.md)). A third `Mode` value replaces `ClusterClient` with unary gRPC calls on the `mesh_transport.v1` contract: central dials each node's `NodeCommandService`, a node posts its deployment ack to central's `CentralCommandService`. Unlike `ClusterClient`, the command plane is authenticated end to end (`GrpcPsk` below) rather than relying on the mesh boundary alone. As of Phase 0 only the contract, DTO mapper, options and the two auth interceptors exist — the compiled default and the docker-dev rig both stay on `ClusterClient`, and setting `Mode=Grpc` today validates the keys below but nothing yet dials or serves over the contract.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `GrpcPsk` | string? | `null` (unset) | Node-side only, required under `Grpc` on a driver-role node: this node's pre-shared key for its own `Cluster`, presented as the bearer on outbound acks and required on inbound `NodeCommandService` calls. Production form is `${secret:OT-MESH-PSK-<clusterId>}`; supply a literal only on a rig, via `MeshTransport__GrpcPsk` — never commit one. |
|
||||
| `CentralGrpcEndpoints` | string[] | `[]` | Node-side only, required under `Grpc` on a driver-role node: full gRPC base URLs (`scheme://host:port`, e.g. `http://central-1:4055` for h2c) of the central pair's `CentralCommandService`, tried in order, first success wins. The `Grpc` counterpart of `CentralContactPoints`, and replaces it — a node on `Grpc` never reads the Akka contact points. |
|
||||
| `DispatchTimeoutSeconds` | int | `10` | Central-side per-node deadline for one `NodeCommandService.Execute` call. Must be positive under `Grpc`; validated on driver-role nodes (covers the central pair, which is always fused). |
|
||||
| `AckTimeoutSeconds` | int | `10` | Node-side per-endpoint deadline for one `CentralCommandService.ReportApply` call. Must be positive under `Grpc`. Applied per endpoint walked in `CentralGrpcEndpoints`, not per ack, so a dead first endpoint costs this deadline before the second is tried. |
|
||||
| `ClusterPsks` | `Dictionary<string,string>` | `{}` (case-insensitive keys) | **Central-side only** override map, keyed by `Cluster` id: `IMeshPskProvider` consults this map *before* the Secrets store when central builds a channel to (or verifies an ack from) a given cluster. Production leaves this empty and resolves `OT-MESH-PSK-{clusterId}` from Secrets instead; the map exists so the docker-dev rig and integration tests can supply literals via the environment (`MeshTransport__ClusterPsks__SITE-A`) without standing up a KEK. |
|
||||
|
||||
**`ClusterPsks` / `OT-MESH-PSK-<clusterId>` and `SecretName`'s allow-list.** `SecretName` (`ZB.MOM.WW.Secrets.Abstractions`) lowercases the secret name and restricts it to `[a-z0-9._/-]` — a `Cluster` id that carries any character outside that set cannot be expressed as `OT-MESH-PSK-<clusterId>` at all, so `MeshPskProvider.TryGetPsk` fails closed for that cluster on every call that reaches the store (`SecretName`'s constructor throws, `MeshPskProvider.ResolveFromStore` catches it, logs a warning, and returns no key) — `MeshTransport:ClusterPsks` is the only path that still works for such an id. The rig's three ids (`MAIN`, `SITE-A`, `SITE-B`) are all within the allow-list and resolve normally either way.
|
||||
|
||||
The `Grpc` mode's node-side comm actors and central-side services are not yet wired to `Mode` — Phase 0 lands only the contract, mapper, options and the two auth interceptors (`MeshCommandAuthInterceptor` node-side, `CentralCommandAuthInterceptor` central-side), both dark. See the [design doc](plans/2026-08-06-clusterclient-to-grpc-design.md) and [tracking doc](plans/2026-08-06-clusterclient-to-grpc-tracking.md) for the remaining phases.
|
||||
|
||||
### `ConfigSource` / `ConfigServe` (config fetch-and-cache)
|
||||
|
||||
- **Purpose:** [per-cluster mesh](plans/2026-07-21-per-cluster-mesh-design.md) **Phase 3**. `ConfigSource:Mode` selects where a **driver** node reads its deployed configuration: `Direct` (read `Deployment.ArtifactBlob` from central SQL, today's behaviour) or `FetchAndCache` (fetch the artifact bytes from central over a gRPC stream, verify SHA-256, cache in LocalDb, and read **only** the cache — no central-SQL config read). `ConfigServe` is the **central** (admin-role) serve side that a `FetchAndCache` node dials.
|
||||
|
||||
@@ -45,20 +45,113 @@ deadline is the only witness. Restart the pair.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — contract + auth + config groundwork — **PENDING**
|
||||
## Phase 0 — contract + auth + config groundwork — **PASS**
|
||||
|
||||
Branch `feat/grpc-phase0-contract-auth` @ `________`. Image rebuilt, all containers recreated.
|
||||
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 | PENDING |
|
||||
| 2 | Deliberate misconfig: a scratch container with `MeshTransport__Mode=Grpc` and **no** `GrpcPsk` refuses to boot, error names `MeshTransport:GrpcPsk` + the env form | PENDING |
|
||||
| 3 | Same with empty `CentralGrpcEndpoints` — refuses, names the key | PENDING |
|
||||
| 4 | Full suite green, 0 warnings, on the branch | PENDING |
|
||||
| 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 — **PENDING**
|
||||
|
||||
@@ -22,7 +22,7 @@ posture).
|
||||
|
||||
| Phase | Scope | Branch | Status | PR / merge SHA | Live gate |
|
||||
|---|---|---|---|---|---|
|
||||
| 0 | Proto + mappers + goldens; both auth interceptors; `IMeshPskProvider`; options/validators (+`Grpc` value, SplitTopology relaxation); rig dark config; stale-comment fixes; FleetStatus issue filed | `feat/grpc-phase0-contract-auth` | **NOT STARTED** | — | PENDING |
|
||||
| 0 | Proto + mappers + goldens; both auth interceptors; `IMeshPskProvider`; options/validators (+`Grpc` value, SplitTopology relaxation); rig dark config; stale-comment fixes; FleetStatus issue filed | `feat/grpc-phase0-contract-auth` | **DONE — AWAITING PR/MERGE (user-owned)** — all 6 tasks complete @ `23555ed0`; every task spec ✅ + code ✅ (Batch B on Opus with a closed fix loop); issues #542 + #543 filed | — | **PASS 4/4** |
|
||||
| 1A | Central→node command plane (`NodeCommandService` on the Telemetry listener; `MeshGrpcCommandDialer`; third mode branch in `CentralCommunicationActor`) — **worktree A** | `feat/grpc-node-command` | NOT STARTED | — | PENDING |
|
||||
| 1B | Node→central ack plane (`CentralCommandService` on the ConfigServe listener; `GrpcApplyAckSender`; third branch in `NodeCommunicationActor`) — **worktree B**, rebases on 1A | `feat/grpc-central-ack` | NOT STARTED | — | PENDING |
|
||||
| 2 | Baseline reproduction on pre-flip build, then full-rig cutover (`OTOPCUA_MESH_MODE` default → `Grpc`) + soak + kill drills | (rig commit) | NOT STARTED | — | PENDING |
|
||||
@@ -45,6 +45,58 @@ phase DoD — PR + merge are user-owned.**
|
||||
| 2026-08-06 | `Dps` mode survives for single-mesh topologies; only `ClusterClient` is deleted; the three stale "Phase 6 deletes DPS" comments get corrected, not obeyed | design §3.6/§4.2 |
|
||||
| 2026-08-06 | No transport interface — third mode branch inside the two choke-point actors (ScadaBridge §7.1 economy) | design §4.3 |
|
||||
| 2026-08-06 | Coexistence granularity: central's mode governs commands, each node's mode governs its acks (per-pair flips possible — better than ScadaBridge's central-wide flag) | design §3.6 |
|
||||
| 2026-08-06 | `AlarmCommandMessage.operation` is a proto `string`, NOT an enum — the CLR `AlarmCommand.Operation` is an unconstrained string, and an enum would silently decode unknown operations to `UNSPECIFIED` (the drop class this migration removes). Plan T0.1's "AlarmCommand verb" enum wording was a plan defect; only `ApplyOutcome` carries the enum-0 rule. T0.2's goldens must pin pass-through of an unrecognized operation string | proto comment + this log (T0.1 review) |
|
||||
| 2026-08-06 | `correlation_id` lives on the envelope AND as inner fields on the three commands whose CLR record carries one — each command round-trips independently in the T0.2 mapper; the envelope covers `AlarmCommand` (no CLR correlation member). For the three, the inner field is authoritative on decode | proto comment + this log (T0.1 review) |
|
||||
|
||||
## Phase 1A security register (from the Batch B Opus code review — HARD GATE: all four close BEFORE `CentralCommandService` is mapped)
|
||||
|
||||
1. **Known-cluster-set membership gate in `TryGetPsk`** — central rebuilds its cluster set from
|
||||
`ClusterNode` rows every `ContactRefreshSeconds`; refuse a cluster id outside that set in
|
||||
O(1) with no store touch. Closes BOTH open review findings at once: (a) the timing oracle
|
||||
that currently defeats the uniform-denial anti-enumeration text (unknown cluster pays a
|
||||
store read on every probe because negatives are deliberately never cached; known cluster is
|
||||
cached — a stable remotely-measurable distinguisher), and (b) the pre-auth resource
|
||||
exhaustion / audit-flood vector (each distinct forged `x-otopcua-cluster` header costs a
|
||||
secret-store read + a `secret.resolve.not-found` audit row into `ConfigAuditLog` via the
|
||||
audit singleton, attacker-chosen `Target` values, before any credential check). Namespace
|
||||
confinement (`OT-MESH-PSK-` prefix + `SecretName` allow-list) means no disclosure risk —
|
||||
this is flood/oracle only.
|
||||
2. **`bool Matches(string clusterId, string presented)` on `IMeshPskProvider`** — keep
|
||||
`FixedTimeEquals` inside the provider so a future verify-side caller cannot write `==` and
|
||||
have it compile. Do before 1B adds the dial-side caller.
|
||||
3. **DI-shape pin test** — `IMeshPskProvider` is `hasAdmin`-only and its pairing with the
|
||||
central interceptor is by convention; a driver-only node injecting it throws on first call,
|
||||
not at boot. Add a Host.IntegrationTests check (admin host resolves it; driver-only host
|
||||
does NOT) when Phase 1 maps the services.
|
||||
4. **Integration gate over real Kestrel h2c** — both new interceptors have unit coverage only;
|
||||
the one-public-ctor reflection test approximates but cannot prove the interceptor is
|
||||
actually invoked. Wire-probe in the 1A/1B live gates (already in the live-gate tables).
|
||||
|
||||
Non-gating notes from the same review: central-side unseeded-PSK is invisible until a deploy
|
||||
stalls (consider a "PSK seeded for every known cluster?" `/hosts`/health surface — the
|
||||
#521/#541 shape); `SecretName` lowercases and allows only `[a-z0-9._/-]` (underscore IS
|
||||
allowed — the review's `Site_A` example was wrong, caught + corrected by the T0.6 agent
|
||||
against the pinned 0.2.3 DLL), so a cluster id with any character outside that set fails
|
||||
closed at the store and must use `ClusterPsks` — documented in `docs/Configuration.md`
|
||||
(rig ids `MAIN`/`SITE-A`/`SITE-B` are fine); sibling interceptors
|
||||
still use `IsNullOrEmpty` (whitespace key opens them) — family-wide backport is out of this
|
||||
program's scope, file alongside the FleetStatus issue in T0.6; consider a non-throwing counter
|
||||
metric if the poison-guard silent catch ever fires in production.
|
||||
|
||||
## Phase 1 dispatch notes (from Task 0.2 reviews — fold into the 1A/1B implementer prompts)
|
||||
|
||||
- The alarm arm of `FromEnvelope` necessarily discards the envelope correlation
|
||||
(`AlarmCommand` has no CLR member for it) — the 1A router must read
|
||||
`envelope.CorrelationId` itself for logging.
|
||||
- `ApplyAck` is the ONE record of the five that never rides `CommandEnvelope`:
|
||||
`GrpcApplyAckSender` (1B) calls `MeshTransportDtoMapper.ToProto(ApplyAck)` directly on
|
||||
`ReportApply` — do not expect (or add) a sixth `ToEnvelope` overload.
|
||||
- Consider a thin try/catch at the `Execute`/`ReportApply` service boundary wrapping
|
||||
`FromEnvelope`/`FromProto` failures with the envelope's correlation_id (readable as a plain
|
||||
string even when inner ids fail to parse) so an operator can correlate a rejected call.
|
||||
- Revisit the untyped `ToEnvelope(object, CorrelationId alarmCorrelationId)` signature when
|
||||
the 1A router lands — if the router prefers one always-honoured transport correlation,
|
||||
change the signature then, before it has more callers.
|
||||
|
||||
## Defect log
|
||||
|
||||
@@ -66,7 +118,8 @@ Checks a gate could not drive, named + carried to the next gate (proportionate-g
|
||||
|
||||
| Issue | What | Status |
|
||||
|---|---|---|
|
||||
| *(Gitea # TBD in Phase 0, T0.6)* | `FleetStatusBroadcaster` derives "fleet" from central's own gossip ring — silently central-pair-blind since mesh Phase 6; pre-existing, transport-independent, out of this program's scope | to file |
|
||||
| [lmxopcua#542](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/542) | `FleetStatusBroadcaster` derives "fleet" from central's own gossip ring — silently central-pair-blind since mesh Phase 6; pre-existing, transport-independent, out of this program's scope | FILED 2026-08-06 |
|
||||
| [lmxopcua#543](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/543) | The three pre-existing auth interceptors (`TelemetryStream`/`ConfigServe`/`LocalDbSync`) treat a whitespace-only key as SET (`IsNullOrEmpty`), so a whitespace `Telemetry:ApiKey` etc. opens the endpoint to a whitespace bearer; the two new mesh interceptors use `IsNullOrWhiteSpace`. Backport is a behaviour change to shipped endpoints — own issue, out of this program's scope | FILED 2026-08-06 |
|
||||
|
||||
## Known constraints carried from the research (do not rediscover)
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"executionModel": "opus-subagents; sequential within a worktree, 1A and 1B concurrent in separate worktrees (git worktree add ../OtOpcUa-1B feat/grpc-central-ack); 1A merges first, 1B rebases; stop at each phase DoD (PR/merge user-owned)",
|
||||
"worktrees": {},
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0.1: mesh_transport.proto + contract lock", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.3 (batch, same agent)"], "status": "pending"},
|
||||
{"id": 1, "subject": "Task 0.2: DTO mapper + round-trip goldens", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": [], "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 0.3: MeshTransportOptions + validators + SplitTopology relaxation", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.1 (batch, same agent)"], "status": "pending"},
|
||||
{"id": 3, "subject": "Task 0.4: node-side MeshCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.5 (batch, same agent)"], "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 0.5: IMeshPskProvider + CentralCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.4 (batch, same agent)"], "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "subject": "Task 0.6: rig config + FleetStatus issue + docs stub + Phase 0 gate", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "small", "parallelizableWith": [], "status": "pending", "blockedBy": [0, 1, 2, 3, 4]},
|
||||
{"id": 0, "subject": "Task 0.1: mesh_transport.proto + contract lock", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.3 (batch, same agent)"], "status": "completed", "commit": "c0ccfa27", "review": "spec ✅ + code ✅"},
|
||||
{"id": 1, "subject": "Task 0.2: DTO mapper + round-trip goldens", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": [], "status": "completed", "blockedBy": [0], "commit": "545413a8", "review": "spec ✅ + code ✅ (serial)"},
|
||||
{"id": 2, "subject": "Task 0.3: MeshTransportOptions + validators + SplitTopology relaxation", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.1 (batch, same agent)"], "status": "completed", "commit": "aa21f61f", "review": "spec ✅ + code ✅"},
|
||||
{"id": 3, "subject": "Task 0.4: node-side MeshCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.5 (batch, same agent)"], "status": "completed", "blockedBy": [2], "commit": "fbda015c + fixes 24e55d33/a4631410", "review": "spec ✅ + code ✅ (opus, fix loop closed)"},
|
||||
{"id": 4, "subject": "Task 0.5: IMeshPskProvider + CentralCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.4 (batch, same agent)"], "status": "completed", "blockedBy": [2], "commit": "68a416e4 + fixes 24e55d33/a4631410", "review": "spec ✅ + code ✅ (opus, fix loop closed); 4 items gated on Phase 1A — see tracking §Phase 1A security register"},
|
||||
{"id": 5, "subject": "Task 0.6: rig config + FleetStatus issue + docs stub + Phase 0 gate", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "small", "parallelizableWith": [], "status": "completed", "blockedBy": [0, 1, 2, 3, 4], "commit": "23555ed0", "review": "code ✅", "gate": "PASS 4/4; issues #542 #543 filed"},
|
||||
{"id": 6, "subject": "Task 1A.1: NodeCommandGrpcService on the telemetry listener", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "standard", "parallelizableWith": ["1B.1 (other worktree)"], "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 1A.2: MeshGrpcCommandDialer + third branch in CentralCommunicationActor", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "high-risk", "parallelizableWith": ["1B.2 (other worktree)"], "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 1A.3: 1A live gate (central pair flipped, rig-only, reverted)", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "small", "parallelizableWith": [], "status": "pending", "blockedBy": [7]},
|
||||
|
||||
@@ -6,11 +6,18 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Mode"/> is a deliberate <b>dark switch</b>, not a permanent knob: both
|
||||
/// transports live in the tree for one phase so the docker-dev rig can run the same
|
||||
/// deployment over each and compare, and so a bad Phase 2 rolls back with a config change
|
||||
/// rather than a revert. Phase 6 deletes the DistributedPubSub branch along with the single
|
||||
/// mesh that made it viable.
|
||||
/// <see cref="Mode"/> is a deliberate <b>dark switch</b>: the transports live in the tree
|
||||
/// side by side so the docker-dev rig can run the same deployment over each and compare, and
|
||||
/// so a bad rollout rolls back with a config change rather than a revert.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The DistributedPubSub branch is retained, not scheduled for deletion.</b> An earlier
|
||||
/// comment here promised Phase 6 would delete it along with the single mesh; that was
|
||||
/// deliberately reversed — see <c>docs/Configuration.md</c> § "The compiled transport-mode
|
||||
/// defaults were deliberately NOT flipped". DPS remains the correct and default transport for
|
||||
/// a single-mesh topology, which every integration test and every profile that leaves this
|
||||
/// key unset still runs. What is being replaced is <see cref="ModeClusterClient"/>, by
|
||||
/// <see cref="ModeGrpc"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Discovery is deliberately asymmetric</b>, matching the sister project. Central
|
||||
@@ -32,9 +39,19 @@ public sealed class MeshTransportOptions
|
||||
public const string ModeClusterClient = "ClusterClient";
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ModeDps"/> (default) or <see cref="ModeClusterClient"/>. Any other value fails
|
||||
/// the host at startup rather than silently falling back — a transport that is neither of
|
||||
/// these delivers nothing, and the symptom would be commands that vanish.
|
||||
/// Commands cross the mesh boundary as unary gRPC calls on the <c>mesh_transport.v1</c>
|
||||
/// contract — central dials each node's <c>NodeCommandService</c>, a node posts its
|
||||
/// deployment ack to central's <c>CentralCommandService</c>. The replacement for
|
||||
/// <see cref="ModeClusterClient"/>; unlike it, the command plane is authenticated
|
||||
/// (<see cref="GrpcPsk"/>).
|
||||
/// </summary>
|
||||
public const string ModeGrpc = "Grpc";
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ModeDps"/> (default), <see cref="ModeClusterClient"/> or
|
||||
/// <see cref="ModeGrpc"/>. Any other value fails the host at startup rather than silently
|
||||
/// falling back — a transport that is none of these delivers nothing, and the symptom would
|
||||
/// be commands that vanish.
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = ModeDps;
|
||||
|
||||
@@ -58,4 +75,91 @@ public sealed class MeshTransportOptions
|
||||
/// process — migration, direct SQL, a second admin node — is picked up without a restart.
|
||||
/// </summary>
|
||||
public int ContactRefreshSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// <c>MeshTransport:CentralGrpcEndpoints</c> — node-side only, and only under
|
||||
/// <see cref="ModeGrpc"/>: the gRPC base addresses of the central pair's
|
||||
/// <c>CentralCommandService</c>, e.g. <c>http://central-1:4055</c>. Full URLs
|
||||
/// (<c>scheme://host:port</c>), not bare <c>host:port</c> — <c>http://</c> is h2c.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tried <b>in order, stopping at the first success</b>, which is what preserves the
|
||||
/// receptionist's one-ack-per-node-per-dispatch semantic: either central node routes the ack
|
||||
/// to the same <c>ConfigPublishCoordinator</c> singleton proxy, so which one answers does not
|
||||
/// matter, but two answering would double-count. The gRPC counterpart of
|
||||
/// <see cref="CentralContactPoints"/>, and it replaces it — a node running
|
||||
/// <see cref="ModeGrpc"/> never reads the Akka contact points.
|
||||
/// </remarks>
|
||||
public string[] CentralGrpcEndpoints { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// <c>MeshTransport:GrpcPsk</c> — this node's pre-shared key for the command plane, one per
|
||||
/// application <c>Cluster</c>. Under <see cref="ModeGrpc"/> it is both the bearer this node
|
||||
/// presents when posting an ack to central and the bearer it requires on inbound
|
||||
/// <c>NodeCommandService</c> calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Production form is a secret reference — <c>${secret:OT-MESH-PSK-<clusterId>}</c>,
|
||||
/// e.g. <c>${secret:OT-MESH-PSK-SITE-A}</c> — resolved pre-host by the existing
|
||||
/// <c>SecretReferenceExpander</c>, so it works on a driver-only node with no ConfigDb.
|
||||
/// Supply a literal only on a rig, via the environment
|
||||
/// (<c>MeshTransport__GrpcPsk</c>); never commit one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Per-Cluster, not fleet-wide, on purpose.</b> The three existing shared-bearer gRPC
|
||||
/// channels are read/telemetry planes; this one carries <c>RestartDriver</c>,
|
||||
/// <c>AlarmCommand</c> and deployment dispatch, so a single fleet key would let one
|
||||
/// compromised site node command every other site and forge acks fleet-wide.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nullable because <b>unset must be distinguishable from blank at the boundary that
|
||||
/// fails closed</b>: the serve-side interceptor rejects every call when no key is
|
||||
/// configured rather than defaulting to an empty comparison that any empty bearer would
|
||||
/// satisfy.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? GrpcPsk { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>MeshTransport:DispatchTimeoutSeconds</c> — central-side deadline, in seconds, for one
|
||||
/// <c>NodeCommandService.Execute</c> call to one node. Must be greater than 0 under
|
||||
/// <see cref="ModeGrpc"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bounded per node because a fan-out dispatch dials every enabled node in the cluster: an
|
||||
/// unbounded call to one unreachable node would stall the commands queued behind it. Expiry
|
||||
/// is logged and dropped — the transport is fire-and-forget and layers no retry, so the
|
||||
/// deploy path's 2-minute apply deadline stays the only recovery mechanism.
|
||||
/// Validation of this key is scoped to driver-role nodes; that covers the central pair only
|
||||
/// because central nodes are always fused (<c>admin,driver,cluster-*</c>). A pure-admin
|
||||
/// central node would leave this unvalidated on the node that uses it.
|
||||
/// </remarks>
|
||||
public int DispatchTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// <c>MeshTransport:AckTimeoutSeconds</c> — node-side deadline, in seconds, for one
|
||||
/// <c>CentralCommandService.ReportApply</c> call to one central endpoint. Must be greater
|
||||
/// than 0 under <see cref="ModeGrpc"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Applied per endpoint, not per ack: the node walks <see cref="CentralGrpcEndpoints"/> in
|
||||
/// order, so a dead first endpoint costs this deadline before the second is tried.
|
||||
/// </remarks>
|
||||
public int AckTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// <c>MeshTransport:ClusterPsks:{clusterId}</c> — central-side override map of per-cluster
|
||||
/// PSKs, consulted <i>before</i> the Secrets store when central builds a channel to (or
|
||||
/// verifies an ack from) a given <c>Cluster</c>. Keyed case-insensitively.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Production leaves this empty</b> and the runtime provider resolves
|
||||
/// <c>OT-MESH-PSK-{clusterId}</c> from the Secrets store instead; the map exists so the
|
||||
/// docker-dev rig and integration tests can supply literals through the environment
|
||||
/// (<c>MeshTransport__ClusterPsks__SITE-A</c>) without standing up a KEK. Central needs a map
|
||||
/// rather than a single key because its cluster set is DB-dynamic — one key per site pair it
|
||||
/// talks to — whereas a node holds exactly its own (<see cref="GrpcPsk"/>).
|
||||
/// </remarks>
|
||||
public Dictionary<string, string> ClusterPsks { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
@@ -15,6 +16,22 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
/// </remarks>
|
||||
public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTransportOptions>
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
||||
/// constructor dependency is safe here. <see cref="IConfiguration"/> — not
|
||||
/// <c>IClusterRoleInfo</c> — is the source of this node's roles: <c>IClusterRoleInfo</c>'s
|
||||
/// implementation needs the <c>ActorSystem</c>, which does not exist yet at
|
||||
/// <c>ValidateOnStart</c> time. Same shape as <see cref="ConfigSourceOptionsValidator"/> and
|
||||
/// <see cref="TelemetryOptionsValidator"/>.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The application configuration, read for <c>Cluster:Roles</c>.</param>
|
||||
public MeshTransportOptionsValidator(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
|
||||
{
|
||||
@@ -25,13 +42,14 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
||||
var isDps = string.Equals(options.Mode, MeshTransportOptions.ModeDps, StringComparison.OrdinalIgnoreCase);
|
||||
var isClusterClient = string.Equals(
|
||||
options.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase);
|
||||
var isGrpc = string.Equals(options.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isDps && !isClusterClient)
|
||||
if (!isDps && !isClusterClient && !isGrpc)
|
||||
{
|
||||
errors.Add(
|
||||
$"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is "
|
||||
+ $"'{options.Mode}'. Expected '{MeshTransportOptions.ModeDps}' or "
|
||||
+ $"'{MeshTransportOptions.ModeClusterClient}'.");
|
||||
+ $"'{options.Mode}'. Expected '{MeshTransportOptions.ModeDps}', "
|
||||
+ $"'{MeshTransportOptions.ModeClusterClient}' or '{MeshTransportOptions.ModeGrpc}'.");
|
||||
}
|
||||
|
||||
if (options.ContactRefreshSeconds <= 0)
|
||||
@@ -76,6 +94,78 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
||||
}
|
||||
}
|
||||
|
||||
// The Grpc surface below is node-side: the PSK a driver presents (and requires), the central
|
||||
// endpoints it posts its ack to, and the two call deadlines. Scoped to the driver role for the
|
||||
// same reason the ClusterClient block above is scoped to its mode — an admin-only central node
|
||||
// dials nodes rather than being dialled and resolves per-cluster keys at runtime through
|
||||
// MeshTransportOptions.ClusterPsks / the Secrets store, so requiring a single GrpcPsk and an
|
||||
// endpoint list of it would demand configuration it never reads. Roles come from
|
||||
// configuration, not IClusterRoleInfo (see the ctor remark), and through
|
||||
// RoleParser.ResolveEffectiveRoles so the OTOPCUA_ROLES fallback applies (Gitea #532) — a node
|
||||
// configured only through the env var is still the driver that has to dial central, and
|
||||
// exempting it here would leave its acks rejected unauthenticated at runtime.
|
||||
var roles = RoleParser.ResolveEffectiveRoles(
|
||||
_configuration.GetSection("Cluster:Roles").Get<string[]>());
|
||||
var isDriver = Array.IndexOf(roles, RoleParser.Driver) >= 0;
|
||||
|
||||
if (isGrpc && isDriver)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.GrpcPsk))
|
||||
{
|
||||
errors.Add(
|
||||
$"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.GrpcPsk)} is "
|
||||
+ $"empty. Under {MeshTransportOptions.ModeGrpc} 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 "
|
||||
+ $"({MeshTransportOptions.SectionName}__{nameof(MeshTransportOptions.GrpcPsk)}), in "
|
||||
+ "production as ${secret:OT-MESH-PSK-<clusterId>}.");
|
||||
}
|
||||
|
||||
if (options.CentralGrpcEndpoints.Length == 0)
|
||||
{
|
||||
errors.Add(
|
||||
$"{MeshTransportOptions.SectionName}:"
|
||||
+ $"{nameof(MeshTransportOptions.CentralGrpcEndpoints)} is empty. Under "
|
||||
+ $"{MeshTransportOptions.ModeGrpc} 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 "
|
||||
+ $"({MeshTransportOptions.SectionName}__"
|
||||
+ $"{nameof(MeshTransportOptions.CentralGrpcEndpoints)}__0).");
|
||||
}
|
||||
|
||||
foreach (var endpoint in options.CentralGrpcEndpoints)
|
||||
{
|
||||
if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
&& !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(
|
||||
$"Central gRPC endpoint '{endpoint}' must start with 'http://' (h2c) or "
|
||||
+ "'https://' — it is a gRPC base address (scheme://host:port), not a bare "
|
||||
+ "host:port and not an Akka remoting address.");
|
||||
}
|
||||
}
|
||||
|
||||
// Both deadlines are per-call and there is no retry behind them, so a non-positive value
|
||||
// is not "wait forever" — it is a call that expires before it is made, i.e. a command
|
||||
// plane that silently delivers nothing.
|
||||
if (options.DispatchTimeoutSeconds <= 0)
|
||||
{
|
||||
errors.Add(
|
||||
$"{MeshTransportOptions.SectionName}:"
|
||||
+ $"{nameof(MeshTransportOptions.DispatchTimeoutSeconds)} must be greater than 0 "
|
||||
+ $"(was {options.DispatchTimeoutSeconds}).");
|
||||
}
|
||||
|
||||
if (options.AckTimeoutSeconds <= 0)
|
||||
{
|
||||
errors.Add(
|
||||
$"{MeshTransportOptions.SectionName}:"
|
||||
+ $"{nameof(MeshTransportOptions.AckTimeoutSeconds)} must be greater than 0 "
|
||||
+ $"(was {options.AckTimeoutSeconds}).");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(string.Join(" ", errors));
|
||||
|
||||
@@ -98,14 +98,22 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTrans
|
||||
var telemetryMode = _configuration["Telemetry:Mode"] ?? TelemetryOptions.ModeDps;
|
||||
var telemetryDialMode = _configuration["TelemetryDial:Mode"] ?? TelemetryDialOptions.ModeDps;
|
||||
|
||||
if (!string.Equals(meshMode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase))
|
||||
// Two accepted values, not one, for the duration of the ClusterClient→gRPC migration: both
|
||||
// cross a mesh boundary, which is the only property this rule cares about. Without the
|
||||
// relaxation the very first rollout step — flipping a split-topology node to Grpc — would be a
|
||||
// host-start failure. It is NOT widened to "anything but Dps": a typo'd mode delivers nothing
|
||||
// just as surely, so the check stays an explicit allow-list and tightens to Grpc-only once
|
||||
// ClusterClient is deleted.
|
||||
if (!string.Equals(meshMode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(meshMode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(
|
||||
$"Cluster:Roles declares a cluster-{clusterId} role (split topology) but "
|
||||
+ $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is "
|
||||
+ $"'{meshMode}'; a split mesh cannot use DistributedPubSub — set "
|
||||
+ $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)}="
|
||||
+ $"{MeshTransportOptions.ModeClusterClient}.");
|
||||
+ $"{MeshTransportOptions.ModeGrpc} (or {MeshTransportOptions.ModeClusterClient}, the "
|
||||
+ "transport it replaces).");
|
||||
}
|
||||
|
||||
if (isDriver
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// The one translation between the five cross-mesh command records and their
|
||||
/// <c>mesh_transport.v1</c> wire shapes (ClusterClient→gRPC migration Phase 0). Both directions live
|
||||
/// here so a field can only be added to one side without the other by making this file
|
||||
/// visibly asymmetric.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nothing here guesses.</b> The transport it replaces — an Akka ClusterClient receptionist —
|
||||
/// dropped an undeliverable command silently, and the entire point of the migration is to stop
|
||||
/// doing that. So every decode that cannot produce a faithful record throws: an unset oneof, an
|
||||
/// <c>APPLY_OUTCOME_UNSPECIFIED</c> ack, an enum value from a newer peer. A loud failure on one
|
||||
/// command is recoverable; a deployment reported applied because an absent enum field decoded to
|
||||
/// 0 is not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Presence, not emptiness.</b> The two nullable members that cross this contract —
|
||||
/// <see cref="AlarmCommand.Comment"/> and <see cref="ApplyAck.FailureReason"/> — are
|
||||
/// <c>optional string</c> on the wire, so <c>null</c> maps to an absent field and <c>""</c> to a
|
||||
/// present-but-empty one. Collapsing them would report an operator's deliberately blank comment
|
||||
/// as "no comment supplied", and a blank failure reason as "no reason recorded".
|
||||
/// <see cref="AlarmCommand.UnshelveAtUtc"/> uses a message field, whose own absence carries the
|
||||
/// null.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Correlation is written twice and read once.</b> Encode stamps the envelope's
|
||||
/// <c>correlation_id</c> and the command's own from the same source. Decode takes the command's
|
||||
/// own for the three records that declare one — that member is domain state the record must
|
||||
/// carry back verbatim — and leaves the envelope's to the transport, which is the only place
|
||||
/// <see cref="AlarmCommand"/>'s correlation can live at all since the record has no member for it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MeshTransportDtoMapper
|
||||
{
|
||||
// ---------------------------------------------------------------- DispatchDeployment
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain dispatch command.</param>
|
||||
/// <returns>The wire <see cref="DispatchDeploymentCommand"/>.</returns>
|
||||
public static DispatchDeploymentCommand ToProto(DispatchDeployment message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new DispatchDeploymentCommand
|
||||
{
|
||||
DeploymentId = message.DeploymentId.ToString(),
|
||||
RevisionHash = message.RevisionHash.Value ?? "",
|
||||
CorrelationId = message.CorrelationId.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire dispatch command.</param>
|
||||
/// <returns>The domain <see cref="DispatchDeployment"/>.</returns>
|
||||
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
|
||||
/// <exception cref="ArgumentException">The revision hash is absent or blank.</exception>
|
||||
public static DispatchDeployment FromProto(DispatchDeploymentCommand proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new DispatchDeployment(
|
||||
DeploymentId.Parse(proto.DeploymentId),
|
||||
RevisionHash.Parse(proto.RevisionHash),
|
||||
CorrelationId.Parse(proto.CorrelationId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RestartDriver
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain restart command.</param>
|
||||
/// <returns>The wire <see cref="RestartDriverCommand"/>.</returns>
|
||||
public static RestartDriverCommand ToProto(RestartDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
// Required (non-optional) proto string setters throw on null and the record's nullable-ref
|
||||
// annotations are compile-time only, so a null reaching here is already a producer bug. Coalesce
|
||||
// rather than throw out of the mapper: a command with a blank field is diagnosable downstream,
|
||||
// whereas a throw here is the silent drop this transport exists to remove.
|
||||
return new RestartDriverCommand
|
||||
{
|
||||
ClusterId = message.ClusterId ?? "",
|
||||
DriverInstanceId = message.DriverInstanceId ?? "",
|
||||
ActorByUserName = message.ActorByUserName ?? "",
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire restart command.</param>
|
||||
/// <returns>The domain <see cref="RestartDriver"/>.</returns>
|
||||
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
|
||||
public static RestartDriver FromProto(RestartDriverCommand proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new RestartDriver(
|
||||
proto.ClusterId,
|
||||
proto.DriverInstanceId,
|
||||
proto.ActorByUserName,
|
||||
Guid.ParseExact(proto.CorrelationId, "N"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ReconnectDriver
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain reconnect command.</param>
|
||||
/// <returns>The wire <see cref="ReconnectDriverCommand"/>.</returns>
|
||||
public static ReconnectDriverCommand ToProto(ReconnectDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new ReconnectDriverCommand
|
||||
{
|
||||
ClusterId = message.ClusterId ?? "",
|
||||
DriverInstanceId = message.DriverInstanceId ?? "",
|
||||
ActorByUserName = message.ActorByUserName ?? "",
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire reconnect command.</param>
|
||||
/// <returns>The domain <see cref="ReconnectDriver"/>.</returns>
|
||||
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
|
||||
public static ReconnectDriver FromProto(ReconnectDriverCommand proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new ReconnectDriver(
|
||||
proto.ClusterId,
|
||||
proto.DriverInstanceId,
|
||||
proto.ActorByUserName,
|
||||
Guid.ParseExact(proto.CorrelationId, "N"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- AlarmCommand
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain alarm command.</param>
|
||||
/// <returns>The wire <see cref="AlarmCommandMessage"/>.</returns>
|
||||
/// <remarks>
|
||||
/// <see cref="AlarmCommand.Operation"/> is copied verbatim. It is an unconstrained string in the
|
||||
/// record and stays one on the wire, so an operation this build does not recognise reaches the
|
||||
/// far side intact instead of collapsing onto an enum's UNSPECIFIED and being dropped.
|
||||
/// </remarks>
|
||||
public static AlarmCommandMessage ToProto(AlarmCommand message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var proto = new AlarmCommandMessage
|
||||
{
|
||||
AlarmId = message.AlarmId ?? "",
|
||||
Operation = message.Operation ?? "",
|
||||
User = message.User ?? "",
|
||||
};
|
||||
|
||||
if (message.Comment is not null)
|
||||
proto.Comment = message.Comment;
|
||||
if (message.UnshelveAtUtc is { } unshelveAt)
|
||||
proto.UnshelveAtUtc = ToUtcTimestamp(unshelveAt);
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire alarm command.</param>
|
||||
/// <returns>The domain <see cref="AlarmCommand"/>.</returns>
|
||||
public static AlarmCommand FromProto(AlarmCommandMessage proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
return new AlarmCommand(
|
||||
proto.AlarmId,
|
||||
proto.Operation,
|
||||
proto.User,
|
||||
proto.HasComment ? proto.Comment : null,
|
||||
proto.UnshelveAtUtc?.ToDateTime());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ApplyAck
|
||||
|
||||
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
|
||||
/// <param name="message">The domain per-node apply acknowledgment.</param>
|
||||
/// <returns>The wire <see cref="ApplyAckMessage"/>.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <see cref="ApplyAck.Outcome"/> is not a declared <see cref="ApplyAckOutcome"/>.
|
||||
/// </exception>
|
||||
public static ApplyAckMessage ToProto(ApplyAck message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var proto = new ApplyAckMessage
|
||||
{
|
||||
DeploymentId = message.DeploymentId.ToString(),
|
||||
NodeId = message.NodeId.Value ?? "",
|
||||
Outcome = message.Outcome switch
|
||||
{
|
||||
ApplyAckOutcome.Applied => ApplyOutcome.Applied,
|
||||
ApplyAckOutcome.Failed => ApplyOutcome.Failed,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(message), message.Outcome, "Unknown ApplyAckOutcome"),
|
||||
},
|
||||
CorrelationId = message.CorrelationId.ToString(),
|
||||
};
|
||||
|
||||
if (message.FailureReason is not null)
|
||||
proto.FailureReason = message.FailureReason;
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the domain record from its wire form.</summary>
|
||||
/// <param name="proto">The wire apply acknowledgment.</param>
|
||||
/// <returns>The domain <see cref="ApplyAck"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The outcome is <c>APPLY_OUTCOME_UNSPECIFIED</c> or a value this build does not know.
|
||||
/// </exception>
|
||||
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
|
||||
/// <exception cref="ArgumentException">The node id is absent or blank.</exception>
|
||||
public static ApplyAck FromProto(ApplyAckMessage proto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proto);
|
||||
|
||||
// proto3 decodes an absent enum field as 0, and the deploy path seals a revision on Applied.
|
||||
// ApplyAckOutcome has no Unknown member to land on, so an unspecified or unrecognised outcome
|
||||
// must refuse rather than pick one of the two real answers — either choice would be a lie about
|
||||
// what the node said, and the "Applied" lie seals a deployment nobody applied.
|
||||
var outcome = proto.Outcome switch
|
||||
{
|
||||
ApplyOutcome.Applied => ApplyAckOutcome.Applied,
|
||||
ApplyOutcome.Failed => ApplyAckOutcome.Failed,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"ApplyAckMessage carries outcome '{proto.Outcome}', which has no ApplyAckOutcome " +
|
||||
"equivalent. Refusing to decode rather than report an unsaid deployment result."),
|
||||
};
|
||||
|
||||
return new ApplyAck(
|
||||
DeploymentId.Parse(proto.DeploymentId),
|
||||
NodeId.Parse(proto.NodeId),
|
||||
outcome,
|
||||
proto.HasFailureReason ? proto.FailureReason : null,
|
||||
CorrelationId.Parse(proto.CorrelationId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Envelope
|
||||
|
||||
/// <summary>Wraps a dispatch command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain dispatch command.</param>
|
||||
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
|
||||
public static CommandEnvelope ToEnvelope(DispatchDeployment message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = message.CorrelationId.ToString(),
|
||||
DispatchDeployment = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Wraps a restart command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain restart command.</param>
|
||||
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
|
||||
public static CommandEnvelope ToEnvelope(RestartDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
RestartDriver = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Wraps a reconnect command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain reconnect command.</param>
|
||||
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
|
||||
public static CommandEnvelope ToEnvelope(ReconnectDriver message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = message.CorrelationId.ToString("N"),
|
||||
ReconnectDriver = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Wraps an alarm command in its transport envelope.</summary>
|
||||
/// <param name="message">The domain alarm command.</param>
|
||||
/// <param name="correlationId">
|
||||
/// Transport correlation. Explicit because <see cref="AlarmCommand"/> is the one record with no
|
||||
/// correlation member of its own, so the envelope is the only place it can be carried.
|
||||
/// </param>
|
||||
/// <returns>The envelope.</returns>
|
||||
public static CommandEnvelope ToEnvelope(AlarmCommand message, CorrelationId correlationId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return new CommandEnvelope
|
||||
{
|
||||
CorrelationId = correlationId.ToString(),
|
||||
AlarmCommand = ToProto(message),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Untyped dispatch for the transport router, which holds its payload as
|
||||
/// <see cref="MeshCommand.Message"/> (an <see cref="object"/>).
|
||||
/// </summary>
|
||||
/// <param name="message">One of the four central→node command records.</param>
|
||||
/// <param name="alarmCorrelationId">
|
||||
/// Consulted <b>only</b> when <paramref name="message"/> is an <see cref="AlarmCommand"/>. The
|
||||
/// other three carry their own correlation and it is authoritative for them, so passing a
|
||||
/// different value here cannot make the envelope disagree with the command inside it.
|
||||
/// </param>
|
||||
/// <returns>The envelope produced by the matching typed overload.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="message"/> is not one of the four handled command records — the transport
|
||||
/// would otherwise carry a payload the far side cannot decode.
|
||||
/// </exception>
|
||||
public static CommandEnvelope ToEnvelope(object message, CorrelationId alarmCorrelationId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
return message switch
|
||||
{
|
||||
DispatchDeployment m => ToEnvelope(m),
|
||||
RestartDriver m => ToEnvelope(m),
|
||||
ReconnectDriver m => ToEnvelope(m),
|
||||
AlarmCommand m => ToEnvelope(m, alarmCorrelationId),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(message), message.GetType().FullName, "Not a cross-mesh command record"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Unwraps a transport envelope into the domain record it carries.</summary>
|
||||
/// <param name="envelope">The received envelope.</param>
|
||||
/// <returns>
|
||||
/// One of <see cref="DispatchDeployment"/>, <see cref="RestartDriver"/>,
|
||||
/// <see cref="ReconnectDriver"/> or <see cref="AlarmCommand"/>, boxed — the node-side handlers
|
||||
/// match on the concrete type exactly as they do under the DPS transport.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The oneof is unset or names a case this build does not handle. Kept unreachable by
|
||||
/// <c>MeshTransportProtoContract</c> and its contract-lock test; it throws anyway because the
|
||||
/// alternative is a command that crosses the mesh and disappears.
|
||||
/// </exception>
|
||||
public static object FromEnvelope(CommandEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
return envelope.CommandCase switch
|
||||
{
|
||||
CommandEnvelope.CommandOneofCase.DispatchDeployment => FromProto(envelope.DispatchDeployment),
|
||||
CommandEnvelope.CommandOneofCase.RestartDriver => FromProto(envelope.RestartDriver),
|
||||
CommandEnvelope.CommandOneofCase.ReconnectDriver => FromProto(envelope.ReconnectDriver),
|
||||
CommandEnvelope.CommandOneofCase.AlarmCommand => FromProto(envelope.AlarmCommand),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"CommandEnvelope (correlation '{envelope.CorrelationId}') carries oneof case " +
|
||||
$"'{envelope.CommandCase}', which this build does not handle."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a domain <see cref="DateTime"/> to a proto <see cref="Timestamp"/> without ever
|
||||
/// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
|
||||
/// already-UTC (the producer contract for every <c>…Utc</c> member here).
|
||||
/// </summary>
|
||||
private static Timestamp ToUtcTimestamp(DateTime dt)
|
||||
{
|
||||
var utc = dt.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => dt,
|
||||
DateTimeKind.Local => dt.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(dt, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
return Timestamp.FromDateTime(utc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Protos;
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth for which <see cref="CommandEnvelope.CommandOneofCase"/> variants
|
||||
/// the cross-mesh command transport actually handles. Both the contract-lock test (which proves
|
||||
/// this list stays in lock-step with the generated oneof) and the oneof↔domain mapper reference
|
||||
/// this array, so adding a fifth command to <c>mesh_transport.proto</c> forces a matching entry
|
||||
/// here or the build/test goes red.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="TelemetryProtoContract"/>, and for a sharper reason: a telemetry variant
|
||||
/// nobody handles loses an observation, whereas a command variant nobody handles loses a
|
||||
/// deployment, a driver restart or an alarm acknowledgement — silently, since the sender's ack
|
||||
/// only ever meant "received".
|
||||
/// </remarks>
|
||||
public static class MeshTransportProtoContract
|
||||
{
|
||||
/// <summary>
|
||||
/// Exactly the four cross-mesh command oneof cases mirrored from the domain records:
|
||||
/// DispatchDeployment / RestartDriver / ReconnectDriver / AlarmCommand. The fifth message on
|
||||
/// this contract, <c>ApplyAckMessage</c>, travels the opposite direction on its own RPC and is
|
||||
/// not a oneof member, so it is deliberately absent from this list.
|
||||
/// </summary>
|
||||
public static readonly CommandEnvelope.CommandOneofCase[] HandledCases =
|
||||
[
|
||||
CommandEnvelope.CommandOneofCase.DispatchDeployment,
|
||||
CommandEnvelope.CommandOneofCase.RestartDriver,
|
||||
CommandEnvelope.CommandOneofCase.ReconnectDriver,
|
||||
CommandEnvelope.CommandOneofCase.AlarmCommand,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mesh_transport.v1;
|
||||
|
||||
option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// ClusterClient→gRPC migration Phase 0: the cross-mesh command plane. The five messages that cross
|
||||
// an application-Cluster boundary today ride an Akka ClusterClient receptionist; this contract is
|
||||
// the replacement carrier. Four are central→node commands, one is the node→central deployment ack.
|
||||
//
|
||||
// The C# namespace deliberately says "MeshTransport", never "MeshCommand" — the latter is already
|
||||
// the CLR envelope record ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh.MeshCommand, and a generated type
|
||||
// of the same name would collide on every file that touches both sides of the mapper.
|
||||
//
|
||||
// Additive-only field evolution: never renumber or reuse a tag; a pre-field peer must read a new
|
||||
// field's proto3 default correctly (which is why nullable domain members are modelled with `optional`
|
||||
// for explicit presence, and why every enum reserves 0 as UNSPECIFIED). The oneof is locked by
|
||||
// MeshTransportProtoContract + its test in the Commons test project.
|
||||
|
||||
// Central → node. Delivered to the node's NodeCommunicationActor, which re-emits the decoded message
|
||||
// on the node's EventStream. ONE-WAY semantics, preserved verbatim from the ClusterClient transport:
|
||||
// the ack means "received and re-emitted", nothing more — not applied, not acknowledged by any
|
||||
// subscriber. No retry is layered on a failure; a failed Execute is logged per node and dropped,
|
||||
// which is an honest upgrade over the receptionist's silent drop rather than a semantic change.
|
||||
service NodeCommandService {
|
||||
rpc Execute (CommandEnvelope) returns (ExecuteAck);
|
||||
}
|
||||
|
||||
// Node → central. Delivered to central's CentralCommunicationActor, which forwards to the
|
||||
// ConfigPublishCoordinator singleton proxy — so an ack landing on either node of the central pair
|
||||
// routes identically, exactly as the receptionist made arrival-node-agnostic. ONE-WAY semantics: the
|
||||
// ack means "handed to the coordinator", not "the deployment is complete".
|
||||
service CentralCommandService {
|
||||
rpc ReportApply (ApplyAckMessage) returns (ReportAck);
|
||||
}
|
||||
|
||||
message CommandEnvelope {
|
||||
// Transport-level correlation, carried for every command including AlarmCommandMessage, which is
|
||||
// the one CLR record with no correlation member of its own. The other three ALSO carry their own
|
||||
// domain correlation field-for-field below; the duplication is deliberate, so each command stays
|
||||
// independently round-trippable through the mapper and the envelope stays loggable on its own.
|
||||
string correlation_id = 1;
|
||||
oneof command {
|
||||
DispatchDeploymentCommand dispatch_deployment = 2; // <- Messages.Deploy.DispatchDeployment
|
||||
RestartDriverCommand restart_driver = 3; // <- Messages.Admin.RestartDriver
|
||||
ReconnectDriverCommand reconnect_driver = 4; // <- Messages.Admin.ReconnectDriver
|
||||
AlarmCommandMessage alarm_command = 5; // <- OpcUa.AlarmCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately empty. A non-OK gRPC status is the ONLY failure signal on this call — putting a
|
||||
// success flag in the body would invite a caller to read OK+false as a distinct outcome the
|
||||
// fire-and-forget contract does not have. Extensible later without a version bump.
|
||||
message ExecuteAck {
|
||||
}
|
||||
|
||||
// Deliberately empty, for the same reason as ExecuteAck.
|
||||
message ReportAck {
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.DispatchDeployment.
|
||||
message DispatchDeploymentCommand {
|
||||
string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form
|
||||
string revision_hash = 2; // RevisionHash — lowercase 64-char SHA-256 hex
|
||||
string correlation_id = 3; // CorrelationId (Guid) in its "N" hex form
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.RestartDriver.
|
||||
message RestartDriverCommand {
|
||||
string cluster_id = 1;
|
||||
string driver_instance_id = 2;
|
||||
string actor_by_user_name = 3;
|
||||
string correlation_id = 4; // Guid in the record; "N" hex form
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.ReconnectDriver.
|
||||
message ReconnectDriverCommand {
|
||||
string cluster_id = 1;
|
||||
string driver_instance_id = 2;
|
||||
string actor_by_user_name = 3;
|
||||
string correlation_id = 4; // Guid in the record; "N" hex form
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AlarmCommand.
|
||||
message AlarmCommandMessage {
|
||||
string alarm_id = 1;
|
||||
// The Part 9 operation (Acknowledge / Confirm / OneShotShelve / TimedShelve / Unshelve / Enable /
|
||||
// Disable / AddComment). A `string` in the CLR record, and kept as one here — the same call the
|
||||
// telemetry contract makes for transition_kind / state / level. Promoting it to a proto enum would
|
||||
// put a lossy translation in the mapper for a set the CLR side does not constrain: an operation the
|
||||
// enum did not know would decode to UNSPECIFIED and the command would be dropped, which is the
|
||||
// exact silent-drop class this migration exists to remove.
|
||||
string operation = 2;
|
||||
string user = 3;
|
||||
optional string comment = 4; // nullable in the record — presence distinguishes null from ""
|
||||
google.protobuf.Timestamp unshelve_at_utc = 5; // DateTime? — absent Timestamp encodes null
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAck.
|
||||
message ApplyAckMessage {
|
||||
string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form
|
||||
string node_id = 2; // NodeId — a logical host name, already a string
|
||||
ApplyOutcome outcome = 3;
|
||||
optional string failure_reason = 4; // nullable in the record — presence distinguishes null from ""
|
||||
string correlation_id = 5; // CorrelationId (Guid) in its "N" hex form
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAckOutcome, shifted by one so 0 stays
|
||||
// meaningless. proto3 decodes an absent enum field as 0; if 0 were Applied, an ack that lost the
|
||||
// field would report a deployment applied when the node said nothing at all — and the deploy path
|
||||
// seals on exactly that signal.
|
||||
enum ApplyOutcome {
|
||||
APPLY_OUTCOME_UNSPECIFIED = 0;
|
||||
APPLY_OUTCOME_APPLIED = 1;
|
||||
APPLY_OUTCOME_FAILED = 2;
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\deployment_artifact.proto" GrpcServices="Both"/>
|
||||
<Protobuf Include="Protos\telemetry.proto" GrpcServices="Both"/>
|
||||
<Protobuf Include="Protos\mesh_transport.proto" GrpcServices="Both"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -88,8 +88,12 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fallback keeps ~12 existing tests (and any node wired before per-cluster mesh Phase 2)
|
||||
/// on exactly the pre-Phase-2 behaviour. It is not a permanent seam: Phase 6 deletes it along
|
||||
/// with the DistributedPubSub branch and the single mesh.
|
||||
/// on exactly the pre-Phase-2 behaviour. The DistributedPubSub branch behind it is
|
||||
/// <b>retained</b>, not scheduled for deletion — an earlier note here promised Phase 6 would
|
||||
/// remove it, which was deliberately reversed (<c>docs/Configuration.md</c> § "The compiled
|
||||
/// transport-mode defaults were deliberately NOT flipped"): DPS is still the correct and
|
||||
/// default transport for a single-mesh topology. What is being replaced is the ClusterClient
|
||||
/// branch, by gRPC.
|
||||
/// </remarks>
|
||||
/// <param name="topic">The legacy DistributedPubSub topic.</param>
|
||||
/// <param name="message">The command.</param>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Central's per-cluster pre-shared key lookup for the gRPC command plane. A node holds exactly
|
||||
/// one key (<c>MeshTransport:GrpcPsk</c>, its own cluster's); central talks to every cluster, so
|
||||
/// it needs one key per <c>Cluster</c> — both to authenticate itself when dialling a node and to
|
||||
/// verify an inbound deployment ack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Fail-closed by contract.</b> Every failure — unknown cluster, absent secret, blank
|
||||
/// value, a store that throws — is reported as "no key" (<c>false</c>), and the caller
|
||||
/// denies the inbound call or skips the outbound dial. There is no arm on which a caller may
|
||||
/// proceed unauthenticated; a missing key must cost delivery, not authentication.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <c>Invalidate</c> hooks exist because central's cluster set is DB-dynamic. They are
|
||||
/// deliberately unconsumed at Phase 0 — Phase 1A wires them to the contact refresh, which is
|
||||
/// the point at which central learns a cluster's membership (and so possibly its key) has
|
||||
/// changed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>Invalidate</c> is not a revocation barrier.</b> It drops the cached entry; it does
|
||||
/// not wait for, cancel, or supersede a resolve already in flight. A lookup that entered the
|
||||
/// store before the call can still publish and return the pre-rotation key to its caller, and
|
||||
/// a lookup racing the eviction can re-cache the old value it had already read. Key rotation
|
||||
/// is therefore eventually consistent by seconds, not atomic — treat <c>Invalidate</c> as
|
||||
/// "stop serving this from memory", never as "this key is now dead everywhere".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMeshPskProvider
|
||||
{
|
||||
/// <summary>Gets the pre-shared key for one application <c>Cluster</c>.</summary>
|
||||
/// <param name="clusterId">The application <c>Cluster</c> id, matched case-insensitively.</param>
|
||||
/// <param name="psk">The key, or <c>null</c> when none could be established.</param>
|
||||
/// <returns><c>true</c> when a usable key was found; otherwise <c>false</c>.</returns>
|
||||
bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk);
|
||||
|
||||
/// <summary>Drops any cached key for one cluster, so the next lookup re-reads the store.</summary>
|
||||
/// <param name="clusterId">The application <c>Cluster</c> id, matched case-insensitively.</param>
|
||||
void Invalidate(string clusterId);
|
||||
|
||||
/// <summary>Drops every cached key.</summary>
|
||||
void InvalidateAll();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed class MeshPskProvider : IMeshPskProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Prefix of the secret holding a cluster's key — the full name is
|
||||
/// <c>OT-MESH-PSK-{clusterId}</c>. The same name a node's
|
||||
/// <c>${secret:OT-MESH-PSK-<clusterId>}</c> reference expands, so central and the node
|
||||
/// read one row rather than two conventions that drift.
|
||||
/// </summary>
|
||||
public const string SecretNamePrefix = "OT-MESH-PSK-";
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on one store read. Not a tuning knob — a backstop: the read runs under the
|
||||
/// <see cref="Lazy{T}"/> publication lock, so a store that never answers would park the
|
||||
/// calling gRPC thread <i>and</i> every other caller queued behind it for that cluster.
|
||||
/// Expiry fails closed like any other fault.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan StoreReadTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly IOptions<MeshTransportOptions> _options;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
private readonly ILogger<MeshPskProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Cached store resolutions, keyed by cluster id. Holds <b>only</b> values that came from the
|
||||
/// Secrets store: the config map is re-read on every lookup (it is an in-memory dictionary),
|
||||
/// which is what keeps the override unconditional rather than merely first-past-the-post.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Lazy{T}"/> under <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> so a
|
||||
/// burst of concurrent inbound acks for one cluster resolves the secret once instead of
|
||||
/// stampeding the store. Negatives are never retained — see <see cref="TryGetPsk"/>.
|
||||
/// </remarks>
|
||||
private readonly ConcurrentDictionary<string, Lazy<string?>> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Creates the provider.</summary>
|
||||
/// <param name="options">Mesh transport options; <c>ClusterPsks</c> is the override map.</param>
|
||||
/// <param name="secretResolver">The shared secret store read seam.</param>
|
||||
/// <param name="logger">Logger for lookup-failure diagnostics.</param>
|
||||
public MeshPskProvider(
|
||||
IOptions<MeshTransportOptions> options,
|
||||
ISecretResolver secretResolver,
|
||||
ILogger<MeshPskProvider> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_options = options;
|
||||
_secretResolver = secretResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
|
||||
{
|
||||
psk = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clusterId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Mesh PSK lookup was asked for a blank cluster id; refusing rather than resolving a "
|
||||
+ "key that would belong to no cluster.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Config map first, on every call. Blank counts as absent: an empty
|
||||
// MeshTransport__ClusterPsks__<id> is an unset environment variable, not an operator
|
||||
// deciding this cluster has an empty key.
|
||||
if (_options.Value.ClusterPsks.TryGetValue(clusterId, out var configured)
|
||||
&& !string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
psk = configured;
|
||||
return true;
|
||||
}
|
||||
|
||||
var entry = _cache.GetOrAdd(
|
||||
clusterId,
|
||||
static (id, self) => new Lazy<string?>(
|
||||
() => self.ResolveFromStore(id), LazyThreadSafetyMode.ExecutionAndPublication),
|
||||
this);
|
||||
|
||||
string? resolved;
|
||||
try
|
||||
{
|
||||
resolved = entry.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ResolveFromStore swallows everything the STORE can throw — but it logs while doing so,
|
||||
// and a logger provider that throws escapes the factory. Lazy under
|
||||
// ExecutionAndPublication caches that exception and rethrows it on every later read, so
|
||||
// without this guard one bad log call would deny the cluster for the life of the process
|
||||
// (nothing calls Invalidate until Phase 1A). Evict instance-keyed, same as the negative
|
||||
// path below, and fail closed.
|
||||
//
|
||||
// Deliberately silent: the most likely reason we are here is that logging is what threw,
|
||||
// so logging the failure is the one action with a real chance of throwing again — out of
|
||||
// an authentication gate, turning a denial into a 500.
|
||||
_cache.TryRemove(new KeyValuePair<string, Lazy<string?>>(clusterId, entry));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
// Never retain a negative. A store that was briefly unreachable would otherwise stay
|
||||
// "no key" for this cluster until something called Invalidate — and at Phase 0 nothing
|
||||
// does, so a transient fault would present as a permanent, silent command outage.
|
||||
_cache.TryRemove(new KeyValuePair<string, Lazy<string?>>(clusterId, entry));
|
||||
return false;
|
||||
}
|
||||
|
||||
psk = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId))
|
||||
return;
|
||||
|
||||
_cache.TryRemove(clusterId, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InvalidateAll() => _cache.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>OT-MESH-PSK-{clusterId}</c> from the Secrets store, converting every failure mode
|
||||
/// into <c>null</c>.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The application <c>Cluster</c> id.</param>
|
||||
/// <returns>The stored key, or <c>null</c> when it could not be established.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Blocking on the async resolver is deliberate. The consumer is a gRPC server
|
||||
/// interceptor, whose <c>Authorize</c> is synchronous in all four sibling interceptors,
|
||||
/// and making this seam async would push that shape onto every one of them. What makes
|
||||
/// the block acceptable is that it is <b>bounded</b> (<see cref="StoreReadTimeout"/>) and
|
||||
/// happens at most once per cluster per process — every later lookup is a dictionary hit.
|
||||
/// The bound does not assume anything about what backs the store: this code cannot see
|
||||
/// whether the resolver reads a local file or crosses a network, so it refuses to wait
|
||||
/// forever on either.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The catch is broad on purpose: this is an authentication boundary, and the set of
|
||||
/// exceptions a secret store can raise (missing KEK, corrupt row, IO) is neither closed
|
||||
/// nor interesting here. Anything other than a usable key is "no key".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private string? ResolveFromStore(string clusterId)
|
||||
{
|
||||
SecretName name;
|
||||
try
|
||||
{
|
||||
name = new SecretName(SecretNamePrefix + clusterId);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// Cluster ids are operator-authored and may carry characters SecretName's allow-list
|
||||
// rejects. Log the id — it is not sensitive — and never the key.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Cluster {ClusterId} cannot be expressed as a secret name, so its mesh PSK cannot be "
|
||||
+ "resolved from the store. Supply it through MeshTransport:ClusterPsks instead, or "
|
||||
+ "rename the cluster to [a-z0-9._/-].",
|
||||
clusterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource(StoreReadTimeout);
|
||||
try
|
||||
{
|
||||
return _secretResolver.GetAsync(name, cts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Covers the timeout too — a cancelled read arrives here as OperationCanceledException
|
||||
// and is treated as every other fault is: no key.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to resolve the mesh PSK for cluster {ClusterId} from secret {SecretName}. "
|
||||
+ "Commands to that cluster and acks from it are refused until it resolves.",
|
||||
clusterId,
|
||||
name.Value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,11 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null in tests and on any node wired before Phase 2, where the fallback reproduces the
|
||||
/// pre-Phase-2 behaviour exactly. The fallback is not a permanent seam — Phase 6 deletes it
|
||||
/// along with the DistributedPubSub branch and the single mesh.
|
||||
/// pre-Phase-2 behaviour exactly. The DistributedPubSub branch behind it is <b>retained</b>,
|
||||
/// not scheduled for deletion — an earlier note here promised Phase 6 would remove it, which
|
||||
/// was deliberately reversed (<c>docs/Configuration.md</c> § "The compiled transport-mode
|
||||
/// defaults were deliberately NOT flipped"): DPS is still the correct and default transport
|
||||
/// for a single-mesh topology. What is being replaced is the ClusterClient branch, by gRPC.
|
||||
/// </remarks>
|
||||
private readonly IActorRef? _meshRouter;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). -->
|
||||
<PackageReference Include="Grpc.Net.Client"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||
<!-- ClusterClient→gRPC: MeshPskProvider resolves OT-MESH-PSK-{clusterId} from the shared
|
||||
secret store when MeshTransport:ClusterPsks carries no override. Abstractions only —
|
||||
the concrete store is the host's choice (SecretsRegistration), not the control plane's. -->
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||
<!-- For ClusterActiveNode — the shared "oldest Up member of role X" primitive. The control
|
||||
plane and the /health/active tier must give the SAME answer for which node is in charge,
|
||||
or the tier reports one node active while the Primary-gated data plane runs on another;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gates the central-side command endpoint (ClusterClient→gRPC migration). The
|
||||
/// <c>CentralCommandService.ReportApply</c> RPC is how a node tells central its deployment
|
||||
/// applied; the coordinator counts those acks, so anything able to reach central's command port
|
||||
/// could otherwise forge a fleet-wide "everything applied". This interceptor is the ONLY inbound
|
||||
/// auth on that endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Scoped by method path.</b> Only calls under
|
||||
/// <c>/mesh_transport.v1.CentralCommandService/</c> are gated; every other method on the
|
||||
/// shared gRPC pipeline (config-serve, and on a fused node sync + telemetry + the node
|
||||
/// command service) passes through untouched, so this shares one <c>AddGrpc</c> registration
|
||||
/// with its siblings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Per-cluster key, not one server key.</b> Unlike the four sibling interceptors there is
|
||||
/// no single expected token: central talks to N site clusters, each with its own PSK. The
|
||||
/// caller names its cluster in <c>x-otopcua-cluster</c> and the bearer is verified against
|
||||
/// <i>that</i> cluster's key via <see cref="IMeshPskProvider"/>. The header is therefore a
|
||||
/// claim the caller must then prove, not a credential — choosing a different cluster only
|
||||
/// changes which key it is graded against. A single fleet-wide key would let one compromised
|
||||
/// site node forge acks on behalf of every other site.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail-closed.</b> A missing or blank cluster header, and a cluster whose key cannot be
|
||||
/// established (unseeded, blank, or a secret store that is down), are both denied. There is
|
||||
/// deliberately no "try every known key" fallback: it would restore exactly the fleet-wide
|
||||
/// key this design exists to avoid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Comparison is <see cref="CryptographicOperations.FixedTimeEquals"/> over UTF-8 bytes, so a
|
||||
/// wrong key cannot be recovered byte-by-byte from response timing. Length differences are
|
||||
/// unavoidably observable and are not sensitive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>All three denials return the same status text</b>, unlike the sibling interceptors,
|
||||
/// which distinguish "no key configured" from "authentication failed". Here that distinction
|
||||
/// would be a <i>content</i> oracle: the caller supplies the cluster id, so a reply that said
|
||||
/// "no key for that cluster" would let an unauthenticated caller read central's cluster set
|
||||
/// straight off the wire. The operator-facing detail goes to the log instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>That is not yet full enumeration resistance, and should not be read as such.</b> A
|
||||
/// timing oracle survives it: an unknown cluster misses the provider's cache and pays a store
|
||||
/// read on every probe, while a known one is answered from memory after the first. Closing
|
||||
/// that needs the lookup to be refused before it reaches the store for any cluster central
|
||||
/// does not know — which is the Phase 1A known-cluster-set gate, where central learns its
|
||||
/// cluster set from the DB. Until then, uniform text buys content-level indistinguishability
|
||||
/// only.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Exactly one public constructor.</b> <c>Grpc.AspNetCore</c> silently stops invoking an
|
||||
/// interceptor that has more than one public ctor — see
|
||||
/// <c>CentralCommandAuthInterceptorTests.HasExactlyOnePublicConstructor</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CentralCommandAuthInterceptor : Interceptor
|
||||
{
|
||||
private const string ServicePrefix = "/mesh_transport.v1.CentralCommandService/";
|
||||
private const string ClusterHeader = "x-otopcua-cluster";
|
||||
|
||||
private readonly IMeshPskProvider _pskProvider;
|
||||
private readonly ILogger<CentralCommandAuthInterceptor> _logger;
|
||||
|
||||
/// <summary>Creates the interceptor.</summary>
|
||||
/// <param name="pskProvider">Supplies the expected bearer token for a given cluster.</param>
|
||||
/// <param name="logger">Logger for denial diagnostics.</param>
|
||||
public CentralCommandAuthInterceptor(
|
||||
IMeshPskProvider pskProvider,
|
||||
ILogger<CentralCommandAuthInterceptor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pskProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_pskProvider = pskProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(request, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(requestStream, responseStream, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
ServerCallContext context,
|
||||
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(requestStream, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(request, responseStream, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> if this is
|
||||
/// a central-command call that does not name a cluster, names one with no resolvable key, or
|
||||
/// does not carry that cluster's bearer token. Non-command calls return immediately.
|
||||
/// </summary>
|
||||
private void Authorize(ServerCallContext context)
|
||||
{
|
||||
if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var clusterId = GrpcRequestHeaders.First(context.RequestHeaders, ClusterHeader);
|
||||
if (string.IsNullOrWhiteSpace(clusterId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a central command call to {Method}: no {Header} header, so there is no "
|
||||
+ "cluster whose pre-shared key the caller could be graded against.",
|
||||
context.Method,
|
||||
ClusterHeader);
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Central command authentication failed."));
|
||||
}
|
||||
|
||||
if (!_pskProvider.TryGetPsk(clusterId, out var expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a central command call to {Method} claiming cluster {ClusterId}: no "
|
||||
+ "pre-shared key could be established for it. Seed the secret "
|
||||
+ "OT-MESH-PSK-<clusterId> on this node, or set "
|
||||
+ "MeshTransport:ClusterPsks:<clusterId> on a rig.",
|
||||
context.Method,
|
||||
clusterId);
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Central command authentication failed."));
|
||||
}
|
||||
|
||||
var presented = GrpcRequestHeaders.BearerToken(context.RequestHeaders);
|
||||
if (presented is null || !FixedTimeEquals(presented, expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a central command call to {Method} claiming cluster {ClusterId}: {Reason}.",
|
||||
context.Method,
|
||||
clusterId,
|
||||
presented is null ? "no bearer token presented" : "bearer token did not match");
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Central command authentication failed."));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string presented, string expected)
|
||||
=> CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Request-metadata reads shared by the two mesh-command auth interceptors
|
||||
/// (<see cref="MeshCommandAuthInterceptor"/>, <see cref="CentralCommandAuthInterceptor"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Exists because those two are documented structural clones of each other, and the one place
|
||||
/// a clone pair can silently diverge is header parsing — where a difference is not a style
|
||||
/// difference but a different answer to "which of these two <c>authorization</c> headers
|
||||
/// counts". They had exactly that divergence before this type: one took the first entry, the
|
||||
/// other scanned for the first <c>Bearer</c>-prefixed entry, so a request carrying
|
||||
/// <c>authorization: junk</c> followed by <c>authorization: Bearer <psk></c> was denied
|
||||
/// by one and accepted by the other.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>First entry wins, and it must parse.</b> A non-<c>Bearer</c> first entry is a failed
|
||||
/// authentication, never a reason to keep looking: scanning past it lets a caller smuggle a
|
||||
/// credential behind a header some intermediary may have inspected, matched or rewritten, and
|
||||
/// makes which credential is checked depend on ordering nothing guarantees.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The three pre-existing sibling interceptors (LocalDb sync, config-serve, telemetry) keep
|
||||
/// their own private copies deliberately — they are a shipped, live-gated wire contract, and
|
||||
/// tightening their header handling is a behaviour change to three running endpoints, not a
|
||||
/// refactor.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class GrpcRequestHeaders
|
||||
{
|
||||
/// <summary>The bearer-token header, lowercased as gRPC puts it on the wire.</summary>
|
||||
internal const string Authorization = "authorization";
|
||||
|
||||
/// <summary>Scheme prefix expected on the <see cref="Authorization"/> value.</summary>
|
||||
internal const string BearerPrefix = "Bearer ";
|
||||
|
||||
/// <summary>Gets the first value carried under <paramref name="name"/>, or null if absent.</summary>
|
||||
/// <param name="headers">The call's request metadata.</param>
|
||||
/// <param name="name">The header name, matched case-insensitively.</param>
|
||||
/// <returns>The first matching value, or <c>null</c>.</returns>
|
||||
internal static string? First(Metadata headers, string name)
|
||||
{
|
||||
// gRPC lowercases header keys on the wire; compare case-insensitively anyway so a hand-built
|
||||
// Metadata in a test behaves the same as a real request.
|
||||
foreach (var entry in headers)
|
||||
{
|
||||
if (string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the token from the first <c>authorization</c> header, or null when that header is
|
||||
/// absent or does not carry the <c>Bearer</c> scheme.
|
||||
/// </summary>
|
||||
/// <param name="headers">The call's request metadata.</param>
|
||||
/// <returns>The bearer token, or <c>null</c>.</returns>
|
||||
internal static string? BearerToken(Metadata headers)
|
||||
{
|
||||
var value = First(headers, Authorization);
|
||||
return value is not null && value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? value[BearerPrefix.Length..]
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gates the node-side command endpoint (ClusterClient→gRPC migration). The
|
||||
/// <c>NodeCommandService.Execute</c> RPC is how central drives this node — restart a driver,
|
||||
/// acknowledge an alarm, dispatch a deployment — so anything able to reach a node's command port
|
||||
/// could otherwise operate the node. This interceptor is the ONLY inbound auth on that endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Scoped by method path.</b> Only calls under
|
||||
/// <c>/mesh_transport.v1.NodeCommandService/</c> are gated; every other method on the shared
|
||||
/// gRPC pipeline (LocalDb sync, config-serve, telemetry) passes through untouched, so this can
|
||||
/// share one <c>AddGrpc</c> registration with <see cref="LocalDbSyncAuthInterceptor"/>,
|
||||
/// <see cref="ConfigServeAuthInterceptor"/> and <see cref="TelemetryStreamAuthInterceptor"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail-closed.</b> With no <c>MeshTransport:GrpcPsk</c> configured, NO command is
|
||||
/// accepted, authenticated or not. Treating "no key" as "no auth required" would expose the
|
||||
/// command plane on exactly the default shape every node ships with — and unlike the three
|
||||
/// sibling endpoints, this one is a write surface, not a read one. Central must present the
|
||||
/// same per-cluster key, so a key typo stops commands outright rather than degrading to
|
||||
/// unauthenticated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No <c>x-otopcua-cluster</c> requirement here, deliberately.</b> That header is a
|
||||
/// central-side routing hint — the caller's own claim about which cluster it is addressing.
|
||||
/// Requiring a node to check it would gate on a value the caller chooses, which proves
|
||||
/// nothing the key has not already proven; the key IS the cluster boundary, so a caller
|
||||
/// holding another cluster's PSK is rejected by the comparison below.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Comparison is <see cref="CryptographicOperations.FixedTimeEquals"/> over UTF-8 bytes, so a
|
||||
/// wrong key cannot be recovered byte-by-byte from response timing. Length differences are
|
||||
/// unavoidably observable and are not sensitive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Header reads go through <see cref="GrpcRequestHeaders"/>, shared with
|
||||
/// <see cref="CentralCommandAuthInterceptor"/> — the two are clones, and header parsing is
|
||||
/// the one place a clone pair can diverge into two different answers about which of several
|
||||
/// <c>authorization</c> headers counts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Exactly one public constructor.</b> <c>Grpc.AspNetCore</c> silently stops invoking an
|
||||
/// interceptor that has more than one public ctor — see
|
||||
/// <c>MeshCommandAuthInterceptorTests.HasExactlyOnePublicConstructor</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MeshCommandAuthInterceptor : Interceptor
|
||||
{
|
||||
private const string ServicePrefix = "/mesh_transport.v1.NodeCommandService/";
|
||||
|
||||
private readonly IOptions<MeshTransportOptions> _options;
|
||||
private readonly ILogger<MeshCommandAuthInterceptor> _logger;
|
||||
|
||||
/// <summary>Creates the interceptor.</summary>
|
||||
/// <param name="options">Mesh transport options; <c>GrpcPsk</c> is the expected bearer token.</param>
|
||||
/// <param name="logger">Logger for denial diagnostics.</param>
|
||||
public MeshCommandAuthInterceptor(
|
||||
IOptions<MeshTransportOptions> options,
|
||||
ILogger<MeshCommandAuthInterceptor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(request, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(requestStream, responseStream, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
ServerCallContext context,
|
||||
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(requestStream, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(request, responseStream, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> if this is
|
||||
/// a node-command call that does not carry the configured bearer token. Non-command calls
|
||||
/// return immediately.
|
||||
/// </summary>
|
||||
private void Authorize(ServerCallContext context)
|
||||
{
|
||||
if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var expected = _options.Value.GrpcPsk;
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a mesh command call to {Method}: no MeshTransport:GrpcPsk is configured, so " +
|
||||
"the node command endpoint is closed. Set this node's per-cluster pre-shared key " +
|
||||
"(MeshTransport__GrpcPsk), ideally as a ${{secret:OT-MESH-PSK-<clusterId>}} reference, " +
|
||||
"and give central the same key for this cluster.",
|
||||
context.Method);
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Node command service is not accepting calls: no pre-shared key is configured on this node."));
|
||||
}
|
||||
|
||||
var presented = GrpcRequestHeaders.BearerToken(context.RequestHeaders);
|
||||
if (presented is null || !FixedTimeEquals(presented, expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a mesh command call to {Method}: {Reason}.",
|
||||
context.Method,
|
||||
presented is null ? "no bearer token presented" : "bearer token did not match");
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Node command authentication failed."));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string presented, string expected)
|
||||
=> CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
@@ -18,6 +18,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
@@ -526,6 +527,13 @@ if (hasAdmin)
|
||||
// hosted service has built the system).
|
||||
builder.Services.TryAddSingleton<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>());
|
||||
builder.Services.AddSingleton<IManualFailoverService, ManualFailoverService>();
|
||||
// Per-cluster mesh PSKs (ClusterClient→gRPC). Admin-only, and singleton because its whole job is
|
||||
// to hold a cache: central talks to N clusters and would otherwise re-read the secret store on
|
||||
// every inbound ack. A node needs nothing of this — it holds exactly its own MeshTransport:GrpcPsk
|
||||
// — so registering it fleet-wide would give a driver-only node a lookup it can never use.
|
||||
// Depends on ISecretResolver, which AddOtOpcUaSecrets registers unconditionally below (DI
|
||||
// registration order is irrelevant; resolution order is not).
|
||||
builder.Services.AddSingleton<IMeshPskProvider, MeshPskProvider>();
|
||||
}
|
||||
|
||||
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
|
||||
@@ -547,6 +555,15 @@ builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
// — the interceptor is the ONLY inbound auth on any endpoint (the libraries verify nothing).
|
||||
// Registered whenever either role is present so an admin-only node still serves config and a
|
||||
// driver-only node still gates sync + telemetry.
|
||||
//
|
||||
// The two mesh-command interceptors (ClusterClient→gRPC migration) join on the same terms:
|
||||
// MeshCommandAuthInterceptor gates the node-side NodeCommandService (driver-role, one key — this
|
||||
// node's own), CentralCommandAuthInterceptor gates the central-side CentralCommandService
|
||||
// (admin-role, one key per cluster via IMeshPskProvider). Both are registered NOW and are pure
|
||||
// pass-throughs until Phase 1 maps those services — an interceptor whose service is unmapped never
|
||||
// sees a matching path. Registering them alongside the services they gate would put a security
|
||||
// gate's wiring in the same commit as the write surface it protects; doing it here means the gate
|
||||
// cannot be forgotten when that surface lights up.
|
||||
if (hasDriver || hasAdmin)
|
||||
{
|
||||
builder.Services.AddGrpc(o =>
|
||||
@@ -555,9 +572,13 @@ if (hasDriver || hasAdmin)
|
||||
{
|
||||
o.Interceptors.Add<LocalDbSyncAuthInterceptor>();
|
||||
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
|
||||
o.Interceptors.Add<MeshCommandAuthInterceptor>();
|
||||
}
|
||||
if (hasAdmin)
|
||||
{
|
||||
o.Interceptors.Add<ConfigServeAuthInterceptor>();
|
||||
o.Interceptors.Add<CentralCommandAuthInterceptor>();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -10,10 +11,35 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
/// trace. Absences are the hardest class of fault to attribute in the field, so the whole point
|
||||
/// of these tests is that the host refuses to start instead.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Role-dependent facts run in <see cref="RoleEnvironmentCollection"/> because the validator
|
||||
/// resolves roles through <c>RoleParser.ResolveEffectiveRoles</c>, which reads
|
||||
/// <c>OTOPCUA_ROLES</c> when <c>Cluster:Roles</c> is empty — a process-wide env var, so the
|
||||
/// whole class is serialized against the other role-reading suites.
|
||||
/// </remarks>
|
||||
[Collection(RoleEnvironmentCollection.Name)]
|
||||
public class MeshTransportOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(MeshTransportOptions o) =>
|
||||
new MeshTransportOptionsValidator().Validate(MeshTransportOptions.SectionName, o);
|
||||
private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles)
|
||||
{
|
||||
var pairs = new Dictionary<string, string?>();
|
||||
for (var i = 0; i < roles.Length; i++)
|
||||
{
|
||||
pairs[$"Cluster:Roles:{i}"] = roles[i];
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
|
||||
|
||||
return new MeshTransportOptionsValidator(configuration)
|
||||
.Validate(MeshTransportOptions.SectionName, o);
|
||||
}
|
||||
|
||||
private static MeshTransportOptions WellFormedGrpc() => new()
|
||||
{
|
||||
Mode = MeshTransportOptions.ModeGrpc,
|
||||
GrpcPsk = "not-a-real-key",
|
||||
CentralGrpcEndpoints = ["http://central-1:4055", "https://central-2:4055"],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Default_options_are_valid()
|
||||
@@ -26,10 +52,23 @@ public class MeshTransportOptionsValidatorTests
|
||||
[Fact]
|
||||
public void Unknown_mode_fails()
|
||||
{
|
||||
var result = Validate(new MeshTransportOptions { Mode = "grpc" });
|
||||
var result = Validate(new MeshTransportOptions { Mode = "receptionist" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("grpc");
|
||||
result.FailureMessage.ShouldContain("receptionist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_mode_message_names_all_three_accepted_values()
|
||||
{
|
||||
// An operator who mistypes the mode gets the full menu, not a partial one — Grpc became a
|
||||
// legal value in Phase 0 and an error that omitted it would send them back to ClusterClient.
|
||||
var result = Validate(new MeshTransportOptions { Mode = "receptionist" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeDps);
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient);
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -135,4 +174,210 @@ public class MeshTransportOptionsValidatorTests
|
||||
],
|
||||
}).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- Grpc mode (ClusterClient→gRPC migration, Phase 0) --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Grpc_is_an_accepted_mode()
|
||||
{
|
||||
Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_mode_matching_is_case_insensitive()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.Mode = "grpc";
|
||||
|
||||
Validate(options, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_without_a_psk_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = null;
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:GrpcPsk");
|
||||
result.FailureMessage.ShouldContain("MeshTransport__GrpcPsk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_with_an_empty_psk_fails()
|
||||
{
|
||||
// Empty is the shape an unset environment variable actually produces, and it is exactly as
|
||||
// fatal as null — a blank bearer authenticates nothing.
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = " ";
|
||||
|
||||
Validate(options, "driver", "cluster-SITE-A").Failed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_psk_value_is_never_echoed_in_a_failure_message()
|
||||
{
|
||||
// The whole point of a validator that reads a secret is that its diagnostics are safe to
|
||||
// paste into a ticket. Naming the key is the contract; printing it is a leak.
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = "super-secret-mesh-psk";
|
||||
options.CentralGrpcEndpoints = ["central-1:4055"];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotContain("super-secret-mesh-psk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_without_central_endpoints_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.CentralGrpcEndpoints = [];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_central_endpoint_that_is_not_an_http_url_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.CentralGrpcEndpoints = ["central-1:4055"];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("central-1:4055");
|
||||
result.FailureMessage.ShouldContain("http://");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_bad_central_endpoint_among_good_ones_still_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.CentralGrpcEndpoints = ["http://central-1:4055", "akka.tcp://otopcua@central-2:4053"];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("akka.tcp://otopcua@central-2:4053");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_with_a_non_positive_dispatch_timeout_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.DispatchTimeoutSeconds = 0;
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:DispatchTimeoutSeconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_with_a_non_positive_ack_timeout_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.AckTimeoutSeconds = -1;
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:AckTimeoutSeconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_admin_only_node_needs_none_of_the_node_side_keys()
|
||||
{
|
||||
// The node-side surface is what a DRIVER uses to dial central and post its ack. An admin-only
|
||||
// central node dials nodes instead and resolves per-cluster keys at runtime, so requiring
|
||||
// these here would make an admin node carry a PSK and an endpoint list it never reads.
|
||||
Validate(
|
||||
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
||||
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_role_less_node_needs_none_of_the_node_side_keys()
|
||||
{
|
||||
// Every in-process test host and every single-mesh node has no declared role. Requiring the
|
||||
// Grpc surface of them would turn a dark-switch value into a tree-wide config obligation.
|
||||
Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roles_come_from_OTOPCUA_ROLES_when_Cluster_Roles_is_empty()
|
||||
{
|
||||
// Roles-source divergence (Gitea #532): a node configured only through the env var is still a
|
||||
// driver node in Akka. A validator reading Cluster:Roles alone would exempt the exact node
|
||||
// that has to dial central, and its acks would be rejected unauthenticated at runtime.
|
||||
using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A"))
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = null;
|
||||
|
||||
Validate(options).Failed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dps_mode_requires_none_of_the_new_Grpc_keys()
|
||||
{
|
||||
// The no-new-obligations invariant: adding a third mode must not make any existing
|
||||
// deployment's configuration invalid. A Dps driver node that sets nothing new still passes.
|
||||
Validate(
|
||||
new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps },
|
||||
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterClient_mode_requires_none_of_the_new_Grpc_keys()
|
||||
{
|
||||
Validate(
|
||||
new MeshTransportOptions
|
||||
{
|
||||
Mode = MeshTransportOptions.ModeClusterClient,
|
||||
CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"],
|
||||
},
|
||||
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterPsks_defaults_to_an_empty_case_insensitive_map()
|
||||
{
|
||||
// Production leaves the map empty and the runtime provider falls back to the Secrets store;
|
||||
// the rig populates it. Case-insensitivity matters because a ClusterId reaches this lookup
|
||||
// from three sources — a role suffix, a DB column and an env key — that do not agree on case.
|
||||
var options = new MeshTransportOptions();
|
||||
|
||||
options.ClusterPsks.ShouldBeEmpty();
|
||||
options.ClusterPsks["MAIN"] = "rig-key";
|
||||
options.ClusterPsks.ContainsKey("main").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterPsks_stays_case_insensitive_after_binding_configuration()
|
||||
{
|
||||
// Asserting the comparer on a fresh instance proves nothing about the shape that reaches
|
||||
// production: the options binder could construct its own Dictionary and drop the comparer
|
||||
// silently, and the symptom would be a rig lookup that misses on a ClusterId spelled the way
|
||||
// the DB spells it. Bind for real and check the comparer survived.
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeshTransport:ClusterPsks:SITE-A"] = "rig-key",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = new MeshTransportOptions();
|
||||
configuration.GetSection(MeshTransportOptions.SectionName).Bind(options);
|
||||
|
||||
options.ClusterPsks["site-a"].ShouldBe("rig-key");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,58 @@ public class SplitTopologyTransportValidatorTests
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient);
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
||||
result.FailureMessage.ShouldContain("SITE-A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_driver_node_on_Grpc_mesh_transport_passes()
|
||||
{
|
||||
// The migration window: Grpc is the transport this rule exists to arrive at, so a rig node
|
||||
// flipped to it must boot. Without this relaxation the very first Grpc rollout step — setting
|
||||
// the mode on a split-topology node — is a host-start failure.
|
||||
Validate(
|
||||
meshMode: MeshTransportOptions.ModeGrpc,
|
||||
telemetryMode: TelemetryOptions.ModeGrpc,
|
||||
telemetryDialMode: null,
|
||||
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_admin_node_on_Grpc_mesh_transport_passes()
|
||||
{
|
||||
Validate(
|
||||
meshMode: MeshTransportOptions.ModeGrpc,
|
||||
telemetryMode: null,
|
||||
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
||||
"admin", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_node_on_Grpc_mesh_transport_is_matched_case_insensitively()
|
||||
{
|
||||
Validate(
|
||||
meshMode: "grpc",
|
||||
telemetryMode: TelemetryOptions.ModeGrpc,
|
||||
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
||||
"admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_node_on_an_unrecognised_mesh_transport_still_fails()
|
||||
{
|
||||
// Widening the rule from one accepted value to two must not widen it to "anything that is not
|
||||
// Dps" — a typo'd mode is still a mode that delivers nothing across the boundary.
|
||||
var result = Validate(
|
||||
meshMode: "receptionist",
|
||||
telemetryMode: TelemetryOptions.ModeGrpc,
|
||||
telemetryDialMode: null,
|
||||
"driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_driver_node_fully_configured_passes()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
using Google.Protobuf;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip goldens for <see cref="MeshTransportDtoMapper"/> — the ClusterClient→gRPC command
|
||||
/// plane's only data contract. Every golden goes domain → proto → <b>serialized bytes</b> → proto →
|
||||
/// domain, because the failure class this suite exists to catch is not a mis-assigned property but a
|
||||
/// member that survives an in-memory copy and vanishes on the wire: proto3 explicit presence is a
|
||||
/// serialization-level property, so a null-vs-<c>""</c> or absent-vs-epoch mistake is invisible
|
||||
/// unless the bytes are actually round-tripped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each record gets one all-members-non-default golden plus a null-variant golden for every
|
||||
/// nullable member, since a nullable member is exactly where a silent drop hides — the record still
|
||||
/// compares equal to a fresh instance built with the default, so nothing else in the system notices.
|
||||
/// </remarks>
|
||||
public class MeshTransportDtoMapperTests
|
||||
{
|
||||
// Deliberately non-default in every position: distinct GUIDs (so a swapped assignment cannot pass),
|
||||
// a real 64-char lowercase SHA-256 hex, and strings that are neither empty nor equal to each other.
|
||||
private static readonly Guid DeploymentGuid = Guid.ParseExact("11111111111111111111111111111111", "N");
|
||||
private static readonly Guid CorrelationGuid = Guid.ParseExact("22222222222222222222222222222222", "N");
|
||||
private static readonly Guid EnvelopeGuid = Guid.ParseExact("33333333333333333333333333333333", "N");
|
||||
|
||||
private const string Sha256Hex = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
|
||||
/// <summary>Serializes and re-parses <paramref name="message"/> so presence is proven on the wire.</summary>
|
||||
private static T Wire<T>(T message)
|
||||
where T : IMessage<T>, new()
|
||||
=> new MessageParser<T>(() => new T()).ParseFrom(message.ToByteArray());
|
||||
|
||||
// ---------------------------------------------------------------- DispatchDeployment
|
||||
|
||||
[Fact]
|
||||
public void DispatchDeployment_RoundTrips_WithEveryMemberSet()
|
||||
{
|
||||
var domain = new DispatchDeployment(
|
||||
new DeploymentId(DeploymentGuid),
|
||||
new RevisionHash(Sha256Hex),
|
||||
new CorrelationId(CorrelationGuid));
|
||||
|
||||
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DispatchDeployment_EncodesIdsInTheirHexNForm()
|
||||
{
|
||||
var proto = MeshTransportDtoMapper.ToProto(new DispatchDeployment(
|
||||
new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)));
|
||||
|
||||
proto.DeploymentId.ShouldBe(DeploymentGuid.ToString("N"));
|
||||
proto.RevisionHash.ShouldBe(Sha256Hex);
|
||||
proto.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RestartDriver / ReconnectDriver
|
||||
|
||||
[Fact]
|
||||
public void RestartDriver_RoundTrips_WithEveryMemberSet()
|
||||
{
|
||||
var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe@zb.local", CorrelationGuid);
|
||||
|
||||
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconnectDriver_RoundTrips_WithEveryMemberSet()
|
||||
{
|
||||
var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "aoperator@zb.local", CorrelationGuid);
|
||||
|
||||
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- AlarmCommand
|
||||
|
||||
[Fact]
|
||||
public void AlarmCommand_RoundTrips_WithEveryMemberSet()
|
||||
{
|
||||
var domain = new AlarmCommand(
|
||||
"alarm-42",
|
||||
"TimedShelve",
|
||||
"jdoe@zb.local",
|
||||
"shelving for maintenance",
|
||||
new DateTime(2026, 8, 6, 13, 45, 12, 345, DateTimeKind.Utc).AddTicks(6789));
|
||||
|
||||
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmCommand_RoundTrips_WithNullComment()
|
||||
{
|
||||
var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", null, null);
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
|
||||
|
||||
back.ShouldBe(domain);
|
||||
back.Comment.ShouldBeNull();
|
||||
back.UnshelveAtUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmCommand_EmptyComment_StaysEmpty_AndIsNotNull()
|
||||
{
|
||||
// The trap: proto3 without explicit presence decodes an absent string as "", collapsing
|
||||
// null and "" onto one value. `optional string comment` keeps them distinct — an operator who
|
||||
// acknowledged with a deliberately blank comment must not be reported as having supplied none.
|
||||
var domain = new AlarmCommand("alarm-42", "AddComment", "jdoe", "", null);
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
|
||||
|
||||
back.ShouldBe(domain);
|
||||
back.Comment.ShouldNotBeNull();
|
||||
back.Comment.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmCommand_UnshelveAt_RoundTripsAsUtc()
|
||||
{
|
||||
// DateTime equality compares ticks and ignores Kind, so record equality alone would pass on a
|
||||
// Local-Kind result whose instant is hours off once anything re-interprets it. Assert Kind.
|
||||
var when = new DateTime(2026, 8, 6, 13, 45, 12, DateTimeKind.Utc).AddTicks(1234567);
|
||||
var domain = new AlarmCommand("alarm-42", "TimedShelve", "jdoe", null, when);
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
|
||||
|
||||
back.UnshelveAtUtc.ShouldNotBeNull();
|
||||
back.UnshelveAtUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
back.UnshelveAtUtc.Value.Ticks.ShouldBe(when.Ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmCommand_UnrecognizedOperation_RoundTripsVerbatim()
|
||||
{
|
||||
// Controller decision: `operation` stays a proto string precisely so an operation this build
|
||||
// does not know travels intact rather than decoding to an enum's UNSPECIFIED and being dropped.
|
||||
var domain = new AlarmCommand("alarm-42", "SomeFutureOperation", "jdoe", null, null);
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
|
||||
|
||||
back.ShouldBe(domain);
|
||||
back.Operation.ShouldBe("SomeFutureOperation");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ApplyAck
|
||||
|
||||
[Theory]
|
||||
[InlineData(ApplyAckOutcome.Applied)]
|
||||
[InlineData(ApplyAckOutcome.Failed)]
|
||||
public void ApplyAck_RoundTrips_WithEveryMemberSet(ApplyAckOutcome outcome)
|
||||
{
|
||||
var domain = new ApplyAck(
|
||||
new DeploymentId(DeploymentGuid),
|
||||
new NodeId("site-a-1"),
|
||||
outcome,
|
||||
"artifact fetch failed: sha mismatch",
|
||||
new CorrelationId(CorrelationGuid));
|
||||
|
||||
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_RoundTrips_WithNullFailureReason()
|
||||
{
|
||||
var domain = new ApplyAck(
|
||||
new DeploymentId(DeploymentGuid),
|
||||
new NodeId("site-a-1"),
|
||||
ApplyAckOutcome.Applied,
|
||||
null,
|
||||
new CorrelationId(CorrelationGuid));
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
|
||||
|
||||
back.ShouldBe(domain);
|
||||
back.FailureReason.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_EmptyFailureReason_StaysEmpty_AndIsNotNull()
|
||||
{
|
||||
var domain = new ApplyAck(
|
||||
new DeploymentId(DeploymentGuid),
|
||||
new NodeId("site-a-1"),
|
||||
ApplyAckOutcome.Failed,
|
||||
"",
|
||||
new CorrelationId(CorrelationGuid));
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
|
||||
|
||||
back.ShouldBe(domain);
|
||||
back.FailureReason.ShouldNotBeNull();
|
||||
back.FailureReason.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_UnspecifiedOutcome_Throws_RatherThanDecodingToAMeaningfulValue()
|
||||
{
|
||||
// The single highest-stakes decode in this contract. proto3 hands back 0 for an absent enum
|
||||
// field, and the deploy path seals on Applied — so 0 must never resolve to Applied (or to
|
||||
// Failed, which would fail a deployment that actually succeeded). ApplyAckOutcome has no
|
||||
// Unknown member to land on, so refusal is the only honest answer.
|
||||
var proto = new ApplyAckMessage
|
||||
{
|
||||
DeploymentId = DeploymentGuid.ToString("N"),
|
||||
NodeId = "site-a-1",
|
||||
Outcome = ApplyOutcome.Unspecified,
|
||||
CorrelationId = CorrelationGuid.ToString("N"),
|
||||
};
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => MeshTransportDtoMapper.FromProto(proto));
|
||||
ex.Message.ShouldContain(nameof(ApplyOutcome.Unspecified));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_UnknownOutcomeValue_Throws()
|
||||
{
|
||||
// A peer built against a newer contract with a third outcome. Same rule: refuse, do not guess.
|
||||
var proto = new ApplyAckMessage
|
||||
{
|
||||
DeploymentId = DeploymentGuid.ToString("N"),
|
||||
NodeId = "site-a-1",
|
||||
Outcome = (ApplyOutcome)99,
|
||||
CorrelationId = CorrelationGuid.ToString("N"),
|
||||
};
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => MeshTransportDtoMapper.FromProto(proto))
|
||||
.Message.ShouldContain("99");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_UnknownClrOutcome_ThrowsOnEncode()
|
||||
{
|
||||
var domain = new ApplyAck(
|
||||
new DeploymentId(DeploymentGuid),
|
||||
new NodeId("site-a-1"),
|
||||
(ApplyAckOutcome)99,
|
||||
null,
|
||||
new CorrelationId(CorrelationGuid));
|
||||
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => MeshTransportDtoMapper.ToProto(domain));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Envelope
|
||||
|
||||
[Fact]
|
||||
public void Envelope_RoundTrips_DispatchDeployment()
|
||||
{
|
||||
var domain = new DispatchDeployment(
|
||||
new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid));
|
||||
|
||||
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
|
||||
|
||||
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.DispatchDeployment);
|
||||
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_RoundTrips_RestartDriver()
|
||||
{
|
||||
var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe", CorrelationGuid);
|
||||
|
||||
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
|
||||
|
||||
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.RestartDriver);
|
||||
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_RoundTrips_ReconnectDriver()
|
||||
{
|
||||
var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "jdoe", CorrelationGuid);
|
||||
|
||||
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
|
||||
|
||||
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.ReconnectDriver);
|
||||
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_RoundTrips_AlarmCommand()
|
||||
{
|
||||
var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", "ack'd", null);
|
||||
|
||||
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain, new CorrelationId(EnvelopeGuid)));
|
||||
|
||||
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.AlarmCommand);
|
||||
envelope.CorrelationId.ShouldBe(EnvelopeGuid.ToString("N"));
|
||||
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_SetsBothCorrelationIds_ForTheThreeSelfCorrelatingCommands()
|
||||
{
|
||||
// Encode writes envelope and inner correlation from the record's own member, so the two can
|
||||
// only diverge if a peer produced them apart — see the decode-precedence golden below.
|
||||
var dispatch = MeshTransportDtoMapper.ToEnvelope(new DispatchDeployment(
|
||||
new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)));
|
||||
dispatch.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
|
||||
dispatch.DispatchDeployment.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
|
||||
|
||||
var restart = MeshTransportDtoMapper.ToEnvelope(
|
||||
new RestartDriver("c", "d", "u", CorrelationGuid));
|
||||
restart.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
|
||||
restart.RestartDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
|
||||
|
||||
var reconnect = MeshTransportDtoMapper.ToEnvelope(
|
||||
new ReconnectDriver("c", "d", "u", CorrelationGuid));
|
||||
reconnect.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
|
||||
reconnect.ReconnectDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_InnerCorrelationWins_OnDecode()
|
||||
{
|
||||
// Controller decision: the inner field is the domain member, the envelope's is transport
|
||||
// metadata. If a peer ever writes them apart, the record must carry what the record declared.
|
||||
var envelope = Wire(new CommandEnvelope
|
||||
{
|
||||
CorrelationId = EnvelopeGuid.ToString("N"),
|
||||
DispatchDeployment = new DispatchDeploymentCommand
|
||||
{
|
||||
DeploymentId = DeploymentGuid.ToString("N"),
|
||||
RevisionHash = Sha256Hex,
|
||||
CorrelationId = CorrelationGuid.ToString("N"),
|
||||
},
|
||||
});
|
||||
|
||||
var decoded = MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBeOfType<DispatchDeployment>();
|
||||
decoded.CorrelationId.Value.ShouldBe(CorrelationGuid);
|
||||
decoded.CorrelationId.Value.ShouldNotBe(EnvelopeGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_InnerCorrelationWins_OnDecode_ForDriverCommands()
|
||||
{
|
||||
var restart = Wire(new CommandEnvelope
|
||||
{
|
||||
CorrelationId = EnvelopeGuid.ToString("N"),
|
||||
RestartDriver = new RestartDriverCommand
|
||||
{
|
||||
ClusterId = "c",
|
||||
DriverInstanceId = "d",
|
||||
ActorByUserName = "u",
|
||||
CorrelationId = CorrelationGuid.ToString("N"),
|
||||
},
|
||||
});
|
||||
MeshTransportDtoMapper.FromEnvelope(restart).ShouldBeOfType<RestartDriver>()
|
||||
.CorrelationId.ShouldBe(CorrelationGuid);
|
||||
|
||||
var reconnect = Wire(new CommandEnvelope
|
||||
{
|
||||
CorrelationId = EnvelopeGuid.ToString("N"),
|
||||
ReconnectDriver = new ReconnectDriverCommand
|
||||
{
|
||||
ClusterId = "c",
|
||||
DriverInstanceId = "d",
|
||||
ActorByUserName = "u",
|
||||
CorrelationId = CorrelationGuid.ToString("N"),
|
||||
},
|
||||
});
|
||||
MeshTransportDtoMapper.FromEnvelope(reconnect).ShouldBeOfType<ReconnectDriver>()
|
||||
.CorrelationId.ShouldBe(CorrelationGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEnvelope_UnsetOneof_Throws()
|
||||
{
|
||||
// Unreachable while MeshTransportProtoContractTests holds the oneof locked, which is exactly
|
||||
// why it must throw rather than return null: the alternative is a command that crosses the
|
||||
// mesh, decodes to nothing and is dropped without a trace.
|
||||
var ex = Should.Throw<InvalidOperationException>(
|
||||
() => MeshTransportDtoMapper.FromEnvelope(new CommandEnvelope { CorrelationId = "x" }));
|
||||
|
||||
ex.Message.ShouldContain(nameof(CommandEnvelope.CommandOneofCase.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEnvelope_UnknownMessageType_Throws()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(
|
||||
() => MeshTransportDtoMapper.ToEnvelope("not a command", new CorrelationId(EnvelopeGuid)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryHandledOneofCase_DecodesToADomainRecord()
|
||||
{
|
||||
// Ties the mapper to the contract-lock list: a fifth oneof case added to the proto and to
|
||||
// HandledCases without a mapper arm fails here rather than at runtime on the far side.
|
||||
var samples = new Dictionary<CommandEnvelope.CommandOneofCase, CommandEnvelope>
|
||||
{
|
||||
[CommandEnvelope.CommandOneofCase.DispatchDeployment] = MeshTransportDtoMapper.ToEnvelope(
|
||||
new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex),
|
||||
new CorrelationId(CorrelationGuid))),
|
||||
[CommandEnvelope.CommandOneofCase.RestartDriver] = MeshTransportDtoMapper.ToEnvelope(
|
||||
new RestartDriver("c", "d", "u", CorrelationGuid)),
|
||||
[CommandEnvelope.CommandOneofCase.ReconnectDriver] = MeshTransportDtoMapper.ToEnvelope(
|
||||
new ReconnectDriver("c", "d", "u", CorrelationGuid)),
|
||||
[CommandEnvelope.CommandOneofCase.AlarmCommand] = MeshTransportDtoMapper.ToEnvelope(
|
||||
new AlarmCommand("a", "Acknowledge", "u", null, null), new CorrelationId(EnvelopeGuid)),
|
||||
};
|
||||
|
||||
samples.Keys.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true);
|
||||
|
||||
foreach (var (expectedCase, envelope) in samples)
|
||||
{
|
||||
envelope.CommandCase.ShouldBe(expectedCase);
|
||||
MeshTransportDtoMapper.FromEnvelope(Wire(envelope)).ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ToEnvelope(object …) dispatch
|
||||
|
||||
[Fact]
|
||||
public void ToEnvelope_ObjectDispatch_MatchesTheTypedOverloads()
|
||||
{
|
||||
// The router holds its command as MeshCommand.Message (an object), so the untyped entry point
|
||||
// must produce byte-identical envelopes to the typed ones it delegates to.
|
||||
var alarmCorrelation = new CorrelationId(EnvelopeGuid);
|
||||
|
||||
object[] commands =
|
||||
[
|
||||
new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex),
|
||||
new CorrelationId(CorrelationGuid)),
|
||||
new RestartDriver("c", "d", "u", CorrelationGuid),
|
||||
new ReconnectDriver("c", "d", "u", CorrelationGuid),
|
||||
new AlarmCommand("a", "Acknowledge", "u", "note", null),
|
||||
];
|
||||
|
||||
CommandEnvelope[] typed =
|
||||
[
|
||||
MeshTransportDtoMapper.ToEnvelope((DispatchDeployment)commands[0]),
|
||||
MeshTransportDtoMapper.ToEnvelope((RestartDriver)commands[1]),
|
||||
MeshTransportDtoMapper.ToEnvelope((ReconnectDriver)commands[2]),
|
||||
MeshTransportDtoMapper.ToEnvelope((AlarmCommand)commands[3], alarmCorrelation),
|
||||
];
|
||||
|
||||
for (var i = 0; i < commands.Length; i++)
|
||||
{
|
||||
MeshTransportDtoMapper.ToEnvelope(commands[i], alarmCorrelation).ShouldBe(typed[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract-lock for the ClusterClient→gRPC command envelope's oneof. If a fifth command is added
|
||||
/// to <c>mesh_transport.proto</c> without adding a matching entry to
|
||||
/// <see cref="MeshTransportProtoContract.HandledCases"/>, this test goes red — the single guard
|
||||
/// that keeps the wire contract and the handled-case set from silently drifting apart. A drift
|
||||
/// here is exactly the failure the whole transport exists to avoid: a command that crosses the
|
||||
/// mesh boundary, decodes to a case nobody handles, and is dropped without a trace.
|
||||
/// </summary>
|
||||
public class MeshTransportProtoContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryOneofVariant_IsAccountedFor()
|
||||
{
|
||||
var variants = System.Enum.GetValues<CommandEnvelope.CommandOneofCase>()
|
||||
.Where(c => c != CommandEnvelope.CommandOneofCase.None).ToArray();
|
||||
|
||||
variants.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_service_base_and_client_types_generate()
|
||||
{
|
||||
// Referencing these types is the whole assertion — GrpcServices="Both" must emit both halves
|
||||
// of both services: central hosts CentralCommandService and dials NodeCommandService, and a
|
||||
// driver node does the mirror image, so neither service can ship client-only or server-only.
|
||||
typeof(NodeCommandService.NodeCommandServiceBase).ShouldNotBeNull();
|
||||
typeof(NodeCommandService.NodeCommandServiceClient).ShouldNotBeNull();
|
||||
typeof(CentralCommandService.CentralCommandServiceBase).ShouldNotBeNull();
|
||||
typeof(CentralCommandService.CentralCommandServiceClient).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_apply_outcome_enum_reserves_zero_as_unspecified()
|
||||
{
|
||||
// proto3 decodes an absent enum field as 0. If 0 meant "Applied", a message that lost the
|
||||
// field in transit — or was written by a peer that predates it — would report a deployment
|
||||
// as successfully applied when nothing was said at all. 0 must stay meaningless.
|
||||
((int)ApplyOutcome.Unspecified).ShouldBe(0);
|
||||
((int)ApplyOutcome.Applied).ShouldNotBe(0);
|
||||
((int)ApplyOutcome.Failed).ShouldNotBe(0);
|
||||
}
|
||||
}
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// ClusterClient→gRPC Phase 0 — central's per-cluster PSK lookup. Central talks to N site
|
||||
/// clusters, each with its own key, so every outbound dial and every inbound ack verification
|
||||
/// goes through here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tests that matter are the adversarial ones: the store being down, the key being absent
|
||||
/// or blank, and the cache handing back a value the store no longer agrees with. Every one of
|
||||
/// those must end in "no key" — the caller then denies or skips, never proceeds unauthenticated.
|
||||
/// </remarks>
|
||||
public sealed class MeshPskProviderTests
|
||||
{
|
||||
private static MeshPskProvider CreateProvider(
|
||||
ISecretResolver resolver,
|
||||
Dictionary<string, string>? clusterPsks = null,
|
||||
ILogger<MeshPskProvider>? logger = null)
|
||||
{
|
||||
var options = new MeshTransportOptions();
|
||||
if (clusterPsks is not null)
|
||||
{
|
||||
foreach (var (key, value) in clusterPsks)
|
||||
options.ClusterPsks[key] = value;
|
||||
}
|
||||
|
||||
return new MeshPskProvider(
|
||||
Options.Create(options), resolver, logger ?? NullLogger<MeshPskProvider>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigMapEntry_IsReturned_WithoutConsultingTheSecretStore()
|
||||
{
|
||||
var resolver = new FakeSecretResolver();
|
||||
var provider = CreateProvider(
|
||||
resolver, new Dictionary<string, string> { ["SITE-A"] = "rig-psk-site-a" });
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
|
||||
|
||||
psk.ShouldBe("rig-psk-site-a");
|
||||
resolver.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigMapEntry_WinsOverTheSecretStore()
|
||||
{
|
||||
// The map exists so a rig can supply literals without standing up a KEK. If the store also
|
||||
// holds a key for that cluster the map must still win, or a stale store row would silently
|
||||
// beat the value the operator just set.
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk" };
|
||||
var provider = CreateProvider(
|
||||
resolver, new Dictionary<string, string> { ["SITE-A"] = "rig-psk-site-a" });
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
|
||||
|
||||
psk.ShouldBe("rig-psk-site-a");
|
||||
resolver.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigMapLookup_IsCaseInsensitive()
|
||||
{
|
||||
// MeshTransport__ClusterPsks__site-a and __SITE-A are the same cluster. The map's comparer
|
||||
// (set on MeshTransportOptions) is what makes that true; this pins the pairing.
|
||||
var resolver = new FakeSecretResolver();
|
||||
var provider = CreateProvider(
|
||||
resolver, new Dictionary<string, string> { ["SITE-A"] = "rig-psk-site-a" });
|
||||
|
||||
provider.TryGetPsk("site-a", out var psk).ShouldBeTrue();
|
||||
|
||||
psk.ShouldBe("rig-psk-site-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankConfigMapEntry_FallsThroughToTheSecretStore()
|
||||
{
|
||||
// A blank map entry is an empty environment variable, not a decision. Treat it as absent
|
||||
// rather than as an empty key that an empty bearer would satisfy.
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk" };
|
||||
var provider = CreateProvider(
|
||||
resolver, new Dictionary<string, string> { ["SITE-A"] = " " });
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
|
||||
|
||||
psk.ShouldBe("stored-psk");
|
||||
resolver.CallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecretStoreValue_IsReturned_UnderTheConventionalName()
|
||||
{
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
|
||||
|
||||
psk.ShouldBe("stored-psk");
|
||||
// SecretName normalizes to lowercase — the same normalization ${secret:OT-MESH-PSK-SITE-A}
|
||||
// goes through in the pre-host expander, so both paths address one row.
|
||||
resolver.LastName.ShouldBe("ot-mesh-psk-site-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCluster_FailsClosed()
|
||||
{
|
||||
// Absent secret + no map entry. The caller denies; it must never fall back to "no auth".
|
||||
var resolver = new FakeSecretResolver { Value = null };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-Z", out var psk).ShouldBeFalse();
|
||||
|
||||
psk.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankSecretValue_FailsClosed()
|
||||
{
|
||||
// A row that decrypts to whitespace is a broken seed, not a key. Accepting it would make
|
||||
// "Bearer " a valid credential for the whole cluster.
|
||||
var resolver = new FakeSecretResolver { Value = " " };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeFalse();
|
||||
|
||||
psk.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolverThrow_FailsClosed()
|
||||
{
|
||||
// The store is a local SQLite file with an env KEK; a missing KEK throws rather than
|
||||
// returning null. Any failure is "no key", never an exception escaping into the gRPC gate.
|
||||
var resolver = new FakeSecretResolver { Throw = new InvalidOperationException("KEK unavailable") };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeFalse();
|
||||
|
||||
psk.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolverThrow_IsNotCached_SoRecoveryNeedsNoInvalidate()
|
||||
{
|
||||
// Caching a negative would turn a transient store fault into a permanent outage for that
|
||||
// cluster, recoverable only by a restart or a hook nothing calls yet.
|
||||
var resolver = new FakeSecretResolver { Throw = new InvalidOperationException("KEK unavailable") };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out _).ShouldBeFalse();
|
||||
|
||||
resolver.Throw = null;
|
||||
resolver.Value = "stored-psk";
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
|
||||
psk.ShouldBe("stored-psk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvedValue_IsCached_SoTheStoreIsHitOnce()
|
||||
{
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
|
||||
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
|
||||
provider.TryGetPsk("site-a", out _).ShouldBeTrue();
|
||||
|
||||
resolver.CallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_ServesTheStaleValue_UntilInvalidated()
|
||||
{
|
||||
// Both directions matter: stale-until-invalidated is the whole reason Invalidate exists, and
|
||||
// an invalidate that did not actually re-read would be indistinguishable from a no-op.
|
||||
var resolver = new FakeSecretResolver { Value = "first-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var first).ShouldBeTrue();
|
||||
first.ShouldBe("first-psk");
|
||||
|
||||
resolver.Value = "rotated-psk";
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var stale).ShouldBeTrue();
|
||||
stale.ShouldBe("first-psk");
|
||||
|
||||
provider.Invalidate("SITE-A");
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var fresh).ShouldBeTrue();
|
||||
fresh.ShouldBe("rotated-psk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_IsCaseInsensitive_AndScopedToOneCluster()
|
||||
{
|
||||
var resolver = new FakeSecretResolver { Value = "first-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
|
||||
provider.TryGetPsk("SITE-B", out _).ShouldBeTrue();
|
||||
|
||||
resolver.Value = "rotated-psk";
|
||||
provider.Invalidate("site-a");
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var a).ShouldBeTrue();
|
||||
a.ShouldBe("rotated-psk");
|
||||
|
||||
provider.TryGetPsk("SITE-B", out var b).ShouldBeTrue();
|
||||
b.ShouldBe("first-psk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_OnACacheMiss_IsANoOp()
|
||||
{
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
Should.NotThrow(() => provider.Invalidate("never-seen"));
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
|
||||
psk.ShouldBe("stored-psk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateAll_DropsEveryCluster()
|
||||
{
|
||||
var resolver = new FakeSecretResolver { Value = "first-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
|
||||
provider.TryGetPsk("SITE-B", out _).ShouldBeTrue();
|
||||
|
||||
resolver.Value = "rotated-psk";
|
||||
provider.InvalidateAll();
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var a).ShouldBeTrue();
|
||||
provider.TryGetPsk("SITE-B", out var b).ShouldBeTrue();
|
||||
|
||||
a.ShouldBe("rotated-psk");
|
||||
b.ShouldBe("rotated-psk");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void BlankClusterId_FailsClosed(string clusterId)
|
||||
{
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk(clusterId, out var psk).ShouldBeFalse();
|
||||
|
||||
psk.ShouldBeNull();
|
||||
resolver.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterIdThatCannotFormASecretName_FailsClosed()
|
||||
{
|
||||
// Cluster ids are operator-authored; SecretName's allow-list is [a-z0-9._/-]. A '$' throws
|
||||
// out of the SecretName ctor, and that throw must become "no key", not a 500 in the gate.
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk" };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
provider.TryGetPsk("SITE$A", out var psk).ShouldBeFalse();
|
||||
|
||||
psk.ShouldBeNull();
|
||||
resolver.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30_000)]
|
||||
public async Task ConcurrentCallers_ResolveOnce_AndAllSeeTheSameValue()
|
||||
{
|
||||
// The gRPC gate calls this from every inbound ack, on arbitrary thread-pool threads. A
|
||||
// non-atomic fill would stampede the store and could hand two callers different keys.
|
||||
//
|
||||
// The shape matters more than the assertion. Callers park BEFORE TryGetPsk and are released
|
||||
// together, so the race is real rather than accidentally serialized by task scheduling —
|
||||
// parking them INSIDE the resolver could never work, because the whole point of the Lazy is
|
||||
// that only one caller ever gets in there. And the resolver's delay RETURNS rather than
|
||||
// waiting on a gate: a regression that lets a second caller reach the store must drive
|
||||
// CallCount to 2 (red), never sit on an exhausted semaphore (hang, which reads as a flake).
|
||||
const int Callers = 16;
|
||||
using var arrived = new CountdownEvent(Callers);
|
||||
using var start = new ManualResetEventSlim(false);
|
||||
var resolver = new FakeSecretResolver { Value = "stored-psk", ResolveDelay = TimeSpan.FromMilliseconds(50) };
|
||||
var provider = CreateProvider(resolver);
|
||||
|
||||
// LongRunning gives each caller a dedicated thread: reaching the arrival gate must not
|
||||
// depend on thread-pool injection, which is rate-limited and made this flake on a
|
||||
// contended box (the gate timed out before all 16 pool threads existed).
|
||||
var callers = Enumerable.Range(0, Callers).Select(_ => Task.Factory.StartNew(() =>
|
||||
{
|
||||
arrived.Signal();
|
||||
start.Wait();
|
||||
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
|
||||
return psk;
|
||||
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)).ToArray();
|
||||
|
||||
arrived.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("all callers should reach the start gate");
|
||||
start.Set();
|
||||
var results = await Task.WhenAll(callers);
|
||||
|
||||
results.ShouldAllBe(r => r == "stored-psk");
|
||||
resolver.CallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APoisonedCacheEntry_IsEvicted_RatherThanDenyingTheClusterForever()
|
||||
{
|
||||
// ResolveFromStore swallows what the store throws, but it LOGS while doing so — and a logger
|
||||
// provider that throws escapes the factory. Lazy under ExecutionAndPublication caches that
|
||||
// exception and rethrows it on every later read, so an unguarded entry.Value would deny this
|
||||
// cluster for the life of the process; nothing calls Invalidate until Phase 1A.
|
||||
var logger = new ThrowingLogger { ThrowOnLog = true };
|
||||
var resolver = new FakeSecretResolver { Throw = new InvalidOperationException("KEK unavailable") };
|
||||
var provider = CreateProvider(resolver, logger: logger);
|
||||
|
||||
// The logger throwing must not escape the gate either — a denial, not a 500.
|
||||
provider.TryGetPsk("SITE-A", out var first).ShouldBeFalse();
|
||||
first.ShouldBeNull();
|
||||
resolver.CallCount.ShouldBe(1);
|
||||
|
||||
logger.ThrowOnLog = false;
|
||||
resolver.Throw = null;
|
||||
resolver.Value = "stored-psk";
|
||||
|
||||
provider.TryGetPsk("SITE-A", out var second).ShouldBeTrue();
|
||||
second.ShouldBe("stored-psk");
|
||||
resolver.CallCount.ShouldBe(2); // reached the store again — the entry was not poisoned
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigMapLookup_StaysCaseInsensitive_ThroughRealConfigurationBinding()
|
||||
{
|
||||
// Writing into the options object pins only the field initializer's comparer. What reaches
|
||||
// production is a BOUND instance, and the binder is free to construct its own Dictionary and
|
||||
// drop the comparer silently — the symptom being a rig lookup that misses on a ClusterId
|
||||
// spelled the way the DB spells it. Bind for real, then go through the PROVIDER.
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeshTransport:ClusterPsks:SITE-A"] = "rig-psk-site-a",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = new MeshTransportOptions();
|
||||
configuration.GetSection(MeshTransportOptions.SectionName).Bind(options);
|
||||
|
||||
var resolver = new FakeSecretResolver();
|
||||
var provider = new MeshPskProvider(
|
||||
Options.Create(options), resolver, NullLogger<MeshPskProvider>.Instance);
|
||||
|
||||
provider.TryGetPsk("site-a", out var psk).ShouldBeTrue();
|
||||
|
||||
psk.ShouldBe("rig-psk-site-a");
|
||||
resolver.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="ISecretResolver"/> whose answer the test controls between calls, so cache
|
||||
/// staleness and invalidation are observable without a real store.
|
||||
/// </summary>
|
||||
private sealed class FakeSecretResolver : ISecretResolver
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
/// <summary>What <see cref="GetAsync"/> returns when <see cref="Throw"/> is null.</summary>
|
||||
public string? Value { get; set; }
|
||||
|
||||
/// <summary>When set, <see cref="GetAsync"/> throws it instead of returning.</summary>
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How long each call takes before answering. Widens the window a concurrent stampede
|
||||
/// would have to exploit — and it RETURNS, so a second entrant shows up as a second call
|
||||
/// rather than as a hang.
|
||||
/// </summary>
|
||||
public TimeSpan ResolveDelay { get; set; } = TimeSpan.Zero;
|
||||
|
||||
/// <summary>How many times the store was consulted.</summary>
|
||||
public int CallCount => Volatile.Read(ref _callCount);
|
||||
|
||||
/// <summary>The name of the most recently requested secret.</summary>
|
||||
public string? LastName { get; private set; }
|
||||
|
||||
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
||||
{
|
||||
LastName = name.Value;
|
||||
Interlocked.Increment(ref _callCount);
|
||||
|
||||
if (ResolveDelay > TimeSpan.Zero)
|
||||
await Task.Delay(ResolveDelay, ct).ConfigureAwait(false);
|
||||
|
||||
if (Throw is not null)
|
||||
throw Throw;
|
||||
|
||||
return Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="ILogger{T}"/> that can be made to throw, standing in for a logger provider
|
||||
/// that faults — the only way the provider's own catch blocks can throw.
|
||||
/// </summary>
|
||||
private sealed class ThrowingLogger : ILogger<MeshPskProvider>
|
||||
{
|
||||
/// <summary>When true, every <see cref="Log"/> call throws.</summary>
|
||||
public bool ThrowOnLog { get; set; }
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (ThrowOnLog)
|
||||
throw new InvalidOperationException("logger provider faulted");
|
||||
}
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// ClusterClient→gRPC Phase 0 — the central-side ack endpoint's inbound gate. Mapping
|
||||
/// <c>CentralCommandService</c> onto the pipeline is a Phase 1 task; this test covers only the
|
||||
/// interceptor's own authorization behavior.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Differs from <see cref="MeshCommandAuthInterceptor"/> in the one way central differs from a
|
||||
/// node: there is no single expected key. The caller names its cluster in
|
||||
/// <c>x-otopcua-cluster</c>, the bearer is verified against <i>that</i> cluster's PSK, and a
|
||||
/// cluster with no resolvable key is denied — so a caller cannot pick a cluster to be graded
|
||||
/// against, only claim one it must then prove.
|
||||
/// </remarks>
|
||||
public sealed class CentralCommandAuthInterceptorTests
|
||||
{
|
||||
private const string ReportApplyMethod = "/mesh_transport.v1.CentralCommandService/ReportApply";
|
||||
private const string OtherMethod = "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
|
||||
|
||||
private static CentralCommandAuthInterceptor CreateInterceptor(params (string Cluster, string Psk)[] keys)
|
||||
=> new(new FakeMeshPskProvider(keys), NullLogger<CentralCommandAuthInterceptor>.Instance);
|
||||
|
||||
private static ServerCallContext CreateContext(
|
||||
string method, string? authorizationHeader, string? clusterHeader)
|
||||
=> CreateContext(
|
||||
method,
|
||||
authorizationHeader is null ? [] : [authorizationHeader],
|
||||
clusterHeader);
|
||||
|
||||
private static ServerCallContext CreateContext(
|
||||
string method, string[] authorizationHeaders, string? clusterHeader)
|
||||
{
|
||||
var headers = new Metadata();
|
||||
foreach (var value in authorizationHeaders)
|
||||
headers.Add("authorization", value);
|
||||
if (clusterHeader is not null)
|
||||
headers.Add("x-otopcua-cluster", clusterHeader);
|
||||
|
||||
return new FakeServerCallContext(method, headers);
|
||||
}
|
||||
|
||||
/// <summary>Invokes the interceptor's unary path (how <c>ReportApply</c> actually runs).</summary>
|
||||
private static Task Invoke(CentralCommandAuthInterceptor interceptor, ServerCallContext context)
|
||||
=> interceptor.UnaryServerHandler<string, string>(
|
||||
"request", context, (_, _) => Task.FromResult("ok"));
|
||||
|
||||
[Fact]
|
||||
public async Task NonCentralCommandMethod_PassesThrough_EvenWithNoKeysAtAll()
|
||||
{
|
||||
// The interceptor shares the AddGrpc pipeline with config-serve (and, on a fused node, sync
|
||||
// and telemetry), so it sees every call. It must be scoped strictly to its own service.
|
||||
var interceptor = CreateInterceptor();
|
||||
var context = CreateContext(OtherMethod, authorizationHeader: null, clusterHeader: null);
|
||||
|
||||
await Invoke(interceptor, context); // no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_WithCorrectBearerForItsCluster_PassesThrough()
|
||||
{
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-A");
|
||||
|
||||
await Invoke(interceptor, context); // no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_WithNoClusterHeader_IsDenied()
|
||||
{
|
||||
// Without the cluster there is no key to grade against. Falling back to "try them all" would
|
||||
// make one compromised site's key valid for forging every other site's acks.
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", clusterHeader: null);
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_WithBlankClusterHeader_IsDenied()
|
||||
{
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", " ");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_ForAClusterWithNoResolvableKey_IsDenied()
|
||||
{
|
||||
// Fail-closed on the provider's "no key" — an unseeded or unreachable key must cost the ack,
|
||||
// never authentication.
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer anything-at-all", "SITE-Z");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_WithAnotherClustersBearer_IsDenied()
|
||||
{
|
||||
// The isolation proof: SITE-B's key does not authenticate a call claiming to be SITE-A.
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"), ("SITE-B", "psk-site-b"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-b", "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_WithNoBearerToken_IsDenied()
|
||||
{
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, authorizationHeader: null, clusterHeader: "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_WithMalformedAuthorizationHeader_IsDenied()
|
||||
{
|
||||
// No "Bearer " prefix at all — must be treated the same as a missing header.
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "psk-site-a", "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_TokenComparison_IsNotAPrefixMatch()
|
||||
{
|
||||
// A prefix/StartsWith comparison would accept a truncated key and make the secret
|
||||
// recoverable one character at a time.
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-", "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportApply_WithAJunkAuthorizationHeaderBeforeTheRealOne_IsDenied()
|
||||
{
|
||||
// First entry wins, and it must parse. Scanning past a non-Bearer header for a later one
|
||||
// would let a caller smuggle its credential behind a header an intermediary may have
|
||||
// inspected or rewritten, and would make WHICH credential is checked depend on ordering
|
||||
// nothing guarantees. Pinned identically on MeshCommandAuthInterceptor.
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(
|
||||
ReportApplyMethod, ["junk", "Bearer psk-site-a"], "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryDenialArm_ReturnsAnIdenticalDetail()
|
||||
{
|
||||
// The anti-enumeration mechanism IS this identity: the caller supplies the cluster id, so a
|
||||
// reply distinguishing "no key for that cluster" from "wrong key" would let an
|
||||
// unauthenticated caller read central's cluster set off the wire. Asserting only StatusCode
|
||||
// would let a well-meaning "more helpful message" edit break it silently.
|
||||
// (Content-level only — the class remark records the timing oracle that survives until the
|
||||
// Phase 1A known-cluster-set gate.)
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
|
||||
var noHeader = await Should.ThrowAsync<RpcException>(() => Invoke(
|
||||
interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", clusterHeader: null)));
|
||||
var unknownCluster = await Should.ThrowAsync<RpcException>(() => Invoke(
|
||||
interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-Z")));
|
||||
var wrongToken = await Should.ThrowAsync<RpcException>(() => Invoke(
|
||||
interceptor, CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A")));
|
||||
var noToken = await Should.ThrowAsync<RpcException>(() => Invoke(
|
||||
interceptor, CreateContext(ReportApplyMethod, authorizationHeader: null, clusterHeader: "SITE-A")));
|
||||
|
||||
unknownCluster.Status.Detail.ShouldBe(noHeader.Status.Detail);
|
||||
wrongToken.Status.Detail.ShouldBe(noHeader.Status.Detail);
|
||||
noToken.Status.Detail.ShouldBe(noHeader.Status.Detail);
|
||||
noHeader.Status.Detail.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerStreamingPath_IsAlsoGated()
|
||||
{
|
||||
// ReportApply is unary today, but gating only that path would leave any future streaming
|
||||
// method on the same service open. Gate every handler shape.
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() =>
|
||||
interceptor.ServerStreamingServerHandler<string, string>(
|
||||
"request", responseStream: null!, context, (_, _, _) => Task.CompletedTask));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientStreamingPath_IsAlsoGated()
|
||||
{
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() =>
|
||||
interceptor.ClientStreamingServerHandler<string, string>(
|
||||
requestStream: null!, context, (_, _) => Task.FromResult("ok")));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplexStreamingPath_IsAlsoGated()
|
||||
{
|
||||
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
|
||||
var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() =>
|
||||
interceptor.DuplexStreamingServerHandler<string, string>(
|
||||
requestStream: null!, responseStream: null!, context, (_, _, _) => Task.CompletedTask));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasExactlyOnePublicConstructor()
|
||||
{
|
||||
// Grpc.AspNetCore silently stops invoking an interceptor with more than one public ctor —
|
||||
// this pins the shape so a well-meaning overload doesn't quietly disable auth in production.
|
||||
var publicCtors = typeof(CentralCommandAuthInterceptor).GetConstructors();
|
||||
|
||||
publicCtors.Length.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IMeshPskProvider"/> over a fixed key set; unknown clusters fail closed.</summary>
|
||||
private sealed class FakeMeshPskProvider(params (string Cluster, string Psk)[] keys) : IMeshPskProvider
|
||||
{
|
||||
private readonly Dictionary<string, string> _keys =
|
||||
keys.ToDictionary(k => k.Cluster, k => k.Psk, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
|
||||
=> _keys.TryGetValue(clusterId, out psk);
|
||||
|
||||
public void Invalidate(string clusterId) { }
|
||||
|
||||
public void InvalidateAll() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="ServerCallContext"/> carrying just a method name and request headers —
|
||||
/// the only two things the interceptor reads. Hand-rolled because
|
||||
/// <c>Grpc.Core.Testing.TestServerCallContext</c> ships only in the retired native package.
|
||||
/// </summary>
|
||||
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
|
||||
: ServerCallContext
|
||||
{
|
||||
protected override string MethodCore => method;
|
||||
protected override string HostCore => "localhost";
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:12345";
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
||||
protected override Metadata ResponseTrailersCore { get; } = [];
|
||||
protected override Status StatusCore { get; set; }
|
||||
protected override WriteOptions? WriteOptionsCore { get; set; }
|
||||
|
||||
protected override AuthContext AuthContextCore { get; } =
|
||||
new(null, new Dictionary<string, List<AuthProperty>>());
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||
ContextPropagationOptions? options)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// ClusterClient→gRPC Phase 0 — the node-side command endpoint's inbound gate. Mapping
|
||||
/// <c>NodeCommandService</c> onto the pipeline is a Phase 1 task; this test covers only the
|
||||
/// interceptor's own authorization behavior.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Same contract as <see cref="TelemetryStreamAuthInterceptor"/>, scoped to
|
||||
/// <c>/mesh_transport.v1.NodeCommandService/</c> — fail-closed on an unconfigured key,
|
||||
/// constant-time comparison, gated on every handler shape so scoping holds regardless of RPC
|
||||
/// shape. Deliberately NO <c>x-otopcua-cluster</c> requirement: that header is a central-side
|
||||
/// routing hint, and requiring it here would gate on a value the caller chooses.
|
||||
/// </remarks>
|
||||
public sealed class MeshCommandAuthInterceptorTests
|
||||
{
|
||||
private const string ExecuteMethod = "/mesh_transport.v1.NodeCommandService/Execute";
|
||||
private const string OtherMethod = "/telemetry.v1.TelemetryStreamService/Subscribe";
|
||||
|
||||
private static MeshCommandAuthInterceptor CreateInterceptor(string? psk)
|
||||
=> new(
|
||||
Options.Create(new MeshTransportOptions { GrpcPsk = psk }),
|
||||
NullLogger<MeshCommandAuthInterceptor>.Instance);
|
||||
|
||||
private static ServerCallContext CreateContext(string method, string? authorizationHeader)
|
||||
=> CreateContext(method, authorizationHeader is null ? [] : [authorizationHeader]);
|
||||
|
||||
private static ServerCallContext CreateContext(string method, string[] authorizationHeaders)
|
||||
{
|
||||
var headers = new Metadata();
|
||||
foreach (var value in authorizationHeaders)
|
||||
headers.Add("authorization", value);
|
||||
|
||||
return new FakeServerCallContext(method, headers);
|
||||
}
|
||||
|
||||
/// <summary>Invokes the interceptor's unary path (how <c>Execute</c> actually runs).</summary>
|
||||
private static Task Invoke(MeshCommandAuthInterceptor interceptor, ServerCallContext context)
|
||||
=> interceptor.UnaryServerHandler<string, string>(
|
||||
"request", context, (_, _) => Task.FromResult("ok"));
|
||||
|
||||
[Fact]
|
||||
public async Task NonMeshCommandMethod_PassesThrough_EvenWithNoPskConfigured()
|
||||
{
|
||||
// The interceptor shares the AddGrpc pipeline with the sync / config-serve / telemetry
|
||||
// services, so it sees every call. It must be scoped strictly to the node command service.
|
||||
var interceptor = CreateInterceptor(psk: null);
|
||||
var context = CreateContext(OtherMethod, authorizationHeader: null);
|
||||
|
||||
await Invoke(interceptor, context); // no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithNoPskConfigured_IsDenied_EvenWithABearerToken()
|
||||
{
|
||||
// Fail-closed. "No PSK configured" is the DEFAULT — treating it as "no auth required" would
|
||||
// let anything that can reach the port drive RestartDriver / AlarmCommand / deployment
|
||||
// dispatch on this node. A token must not help.
|
||||
var interceptor = CreateInterceptor(psk: null);
|
||||
var context = CreateContext(ExecuteMethod, "Bearer anything-at-all");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithBlankPskConfigured_IsDenied()
|
||||
{
|
||||
// Blank is distinct from unset in the options (GrpcPsk is nullable on purpose) but must be
|
||||
// treated identically here: an empty expected token would be satisfied by an empty bearer.
|
||||
var interceptor = CreateInterceptor(" ");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer ");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithNoBearerToken_IsDenied()
|
||||
{
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, authorizationHeader: null);
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithMalformedAuthorizationHeader_IsDenied()
|
||||
{
|
||||
// No "Bearer " prefix at all — must be treated the same as a missing header.
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, "the-cluster-psk");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithWrongBearerToken_IsDenied()
|
||||
{
|
||||
// This is also the isolation proof: a node holding SITE-A's PSK rejects a caller presenting
|
||||
// SITE-B's. Nothing about the cluster id is consulted — only whether the key matches.
|
||||
var interceptor = CreateInterceptor("dev-mesh-psk-SITE-A");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer dev-mesh-psk-SITE-B");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithCorrectBearerToken_PassesThrough()
|
||||
{
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-psk");
|
||||
|
||||
await Invoke(interceptor, context); // no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithCorrectBearerToken_AndNoClusterHeader_PassesThrough()
|
||||
{
|
||||
// x-otopcua-cluster is a central-side routing hint. Requiring it here would gate on a value
|
||||
// the caller supplies about itself, which proves nothing the PSK has not already proven.
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-psk");
|
||||
|
||||
await Invoke(interceptor, context); // no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_WithAJunkAuthorizationHeaderBeforeTheRealOne_IsDenied()
|
||||
{
|
||||
// First entry wins, and it must parse. This interceptor originally scanned past a non-Bearer
|
||||
// header for a later Bearer one while its central-side clone took the first entry, so the
|
||||
// two clones gave OPPOSITE answers to this exact request. Both now share
|
||||
// GrpcRequestHeaders and both deny it; pinned identically on CentralCommandAuthInterceptor.
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, ["junk", "Bearer the-cluster-psk"]);
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteMethod_TokenComparison_IsNotAPrefixMatch()
|
||||
{
|
||||
// A prefix/StartsWith comparison would accept a truncated key and make the secret
|
||||
// recoverable one character at a time.
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-ps");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerStreamingPath_IsAlsoGated()
|
||||
{
|
||||
// Execute is unary today, but gating only that path would leave any future streaming method
|
||||
// on the same service open. Gate every handler shape.
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() =>
|
||||
interceptor.ServerStreamingServerHandler<string, string>(
|
||||
"request", responseStream: null!, context, (_, _, _) => Task.CompletedTask));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientStreamingPath_IsAlsoGated()
|
||||
{
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() =>
|
||||
interceptor.ClientStreamingServerHandler<string, string>(
|
||||
requestStream: null!, context, (_, _) => Task.FromResult("ok")));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplexStreamingPath_IsAlsoGated()
|
||||
{
|
||||
var interceptor = CreateInterceptor("the-cluster-psk");
|
||||
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
|
||||
|
||||
var ex = await Should.ThrowAsync<RpcException>(() =>
|
||||
interceptor.DuplexStreamingServerHandler<string, string>(
|
||||
requestStream: null!, responseStream: null!, context, (_, _, _) => Task.CompletedTask));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasExactlyOnePublicConstructor()
|
||||
{
|
||||
// Grpc.AspNetCore silently stops invoking an interceptor with more than one public ctor —
|
||||
// this pins the shape so a well-meaning overload doesn't quietly disable auth in production.
|
||||
var publicCtors = typeof(MeshCommandAuthInterceptor).GetConstructors();
|
||||
|
||||
publicCtors.Length.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="ServerCallContext"/> carrying just a method name and request headers —
|
||||
/// the only two things the interceptor reads. Hand-rolled because
|
||||
/// <c>Grpc.Core.Testing.TestServerCallContext</c> ships only in the retired native package.
|
||||
/// </summary>
|
||||
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
|
||||
: ServerCallContext
|
||||
{
|
||||
protected override string MethodCore => method;
|
||||
protected override string HostCore => "localhost";
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:12345";
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
||||
protected override Metadata ResponseTrailersCore { get; } = [];
|
||||
protected override Status StatusCore { get; set; }
|
||||
protected override WriteOptions? WriteOptionsCore { get; set; }
|
||||
|
||||
protected override AuthContext AuthContextCore { get; } =
|
||||
new(null, new Dictionary<string, List<AuthProperty>>());
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||
ContextPropagationOptions? options)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user