Merge branch 'master' into fix/r3-02-stale-good-family
# Conflicts: # src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs # tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs
This commit is contained in:
@@ -839,7 +839,12 @@ Two things to know before relying on any of this:
|
|||||||
item is queued `BadUserAccessDenied` and then **detached from its `MonitoredNode2` fan-out — NOT set
|
item is queued `BadUserAccessDenied` and then **detached from its `MonitoredNode2` fan-out — NOT set
|
||||||
`MonitoringMode.Disabled`**: disabling discards the item's queued notifications, so the client is
|
`MonitoringMode.Disabled`**: disabling discards the item's queued notifications, so the client is
|
||||||
silenced without ever being told why (caught only by the over-the-wire test). Detaching is also
|
silenced without ever being told why (caught only by the over-the-wire test). Detaching is also
|
||||||
reversible, so restoring the grant resumes the feed with no client action.
|
reversible, so restoring the grant resumes the feed with no client action. ⚠️ **`Changed` is no longer
|
||||||
|
the only trigger (arch-review 03/S19):** `AclRevalidationTimer`, armed by `OtOpcUaServerHostedService`
|
||||||
|
at `Security:Acl:RevalidationIntervalSeconds` (default 60 s, `0` disables, inert while
|
||||||
|
`Security:Acl:Enabled` is false), runs the same pass on the clock — because `ExpiresUtc` passes with
|
||||||
|
the clock, not with a deployment. The pass's verdict memo is **pass-local and must stay that way**;
|
||||||
|
hoisting it across ticks would serve an expiry that landed between two ticks the earlier tick's allow.
|
||||||
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
||||||
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
||||||
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
|
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
|
||||||
@@ -851,9 +856,28 @@ Two things to know before relying on any of this:
|
|||||||
deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them
|
deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them
|
||||||
node-side alongside the cluster's `NodeAcl` rows. `OpcUaPublishActor` installs both on
|
node-side alongside the cluster's `NodeAcl` rows. `OpcUaPublishActor` installs both on
|
||||||
`IDataPlaneAuthoritySource` on every apply, **before** its `plan.IsEmpty` early-return, so a deployment
|
`IDataPlaneAuthoritySource` on every apply, **before** its `plan.IsEmpty` early-return, so a deployment
|
||||||
that only revokes a key still lands. A node with a ConfigDb keeps `ConfigDbApiKeyStore` (per-request
|
that only revokes a key still lands. A node with a ConfigDb keeps `ConfigDbApiKeyStore` (per-activation
|
||||||
read); a driver-only node gets `ArtifactApiKeyStore`. ⚠️ **The consequence is asymmetric revocation:
|
read, wrapped — see below); a driver-only node gets `ArtifactApiKeyStore`. ⚠️ **The asymmetry is
|
||||||
immediate on a ConfigDb node, next-deployment on a driver-only one.**
|
ACTIVATION-scoped, not session-scoped — this line used to say "immediate on a ConfigDb node" and that was
|
||||||
|
wrong.** Clearing `Enabled` in the ConfigDb stops the **next session activation** on an admin/fused node
|
||||||
|
(bounded by `Security:ApiKeys:StoreCacheSeconds`, default 5 s) and takes until the next deployment on a
|
||||||
|
driver-only one — but an **already-open** session re-checks its key against the in-memory *artifact
|
||||||
|
snapshot* (`DataPlaneAuthorizer.Evaluate` → `snapshot.FindUsableKey`), never against `IApiKeyStore`, so on
|
||||||
|
**both** node kinds an `Enabled` revocation reaches an open session only at the next deployment.
|
||||||
|
**Expiry is the exception**: `FindUsableKey` compares `ExpiresUtc` against the authorizer's clock on every
|
||||||
|
call, so it is immediate everywhere — and since arch-review 03/S19 it reaches live subscriptions too, on
|
||||||
|
the host's `Security:Acl:RevalidationIntervalSeconds` timer (default 60 s; the session is **silenced with
|
||||||
|
`BadUserAccessDenied`, not closed**, and recovers on re-activation). ⚠️ Event/alarm monitored items are
|
||||||
|
revalidated by neither trigger — pre-existing, and a named residual.
|
||||||
|
- ⚠️ **The activation lookup is BOUNDED (arch-review 03/S18).** The SDK raises impersonation synchronously
|
||||||
|
under a `SessionManager`-wide lock, so a ConfigDb that is *slow* rather than *refusing* stalled **every**
|
||||||
|
session activation on the server, Anonymous and X.509 included. `BoundedApiKeyStore` wraps the ConfigDb
|
||||||
|
store — and only it, never `ArtifactApiKeyStore`, which is two in-memory dictionary probes — with
|
||||||
|
`Security:ApiKeys:StoreTimeoutMs` (2000; exceeding it **denies**, via the authenticator's existing
|
||||||
|
catch-all) plus a `StoreCacheSeconds` (5) read-through cache. Faults and timeouts are never cached, a
|
||||||
|
cached hit whose `ExpiresUtc` has passed is re-read rather than served, and a timeout logs a distinct
|
||||||
|
Warning + `otopcua.opcua.apikey_store.bounded{outcome=timeout}`. Both knobs at `0` restore the old
|
||||||
|
behaviour exactly.
|
||||||
- ⚠️ **The auth generation is a node-local COUNTER driven by the `RevisionHash`, not derived from its
|
- ⚠️ **The auth generation is a node-local COUNTER driven by the `RevisionHash`, not derived from its
|
||||||
bits.** `PermissionTrieCache` treats the highest `GenerationId` as current, so a generation taken from
|
bits.** `PermissionTrieCache` treats the highest `GenerationId` as current, so a generation taken from
|
||||||
hash bytes would install a *lower* number about half the time and the cache would go on serving the
|
hash bytes would install a *lower* number about half the time and the cache would go on serving the
|
||||||
|
|||||||
@@ -352,3 +352,87 @@ T10 last — and its step-0 control runs on master BEFORE the branch is merged
|
|||||||
```
|
```
|
||||||
|
|
||||||
Overall effort: **~half a dev-day code+tests, plus the two-pass rig session.** The Critical itself is five lines; everything else in this plan exists because the five lines were invisible to a seven-phase live-gate program.
|
Overall effort: **~half a dev-day code+tests, plus the two-pass rig session.** The Critical itself is five lines; everything else in this plan exists because the five lines were invisible to a seven-phase live-gate program.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution notes (T1–T9, 2026-08-20)
|
||||||
|
|
||||||
|
Branch `fix/r3-01-mesh-grpc-allowanonymous`, one commit per task as specified. T1–T9 all landed;
|
||||||
|
T10 (live gate) left `pending` for the orchestrator per the run brief. Every code-touching task
|
||||||
|
(T1–T7) was verified red-before-fix by hand — for T2–T5 by asserting against the un-fixed
|
||||||
|
`MeshGrpcEndpoints.cs`/`Program.cs` (via a temporary `git show <parent-commit>:<path>` swap,
|
||||||
|
rebuild, run, then restore — confirmed byte-identical restore via `git diff --stat` each time)
|
||||||
|
and observing the expected failure before reapplying the fix. Two things surfaced during that
|
||||||
|
process that are worth recording:
|
||||||
|
|
||||||
|
1. **The census's endpoint filter could not stay a "route pattern starts with `/`" heuristic once
|
||||||
|
T3's empty-prefix `MapGroup` landed.** `RealMethodEndpoints` in `MeshGrpcAnonymousMetadataTests`
|
||||||
|
originally filtered on `RoutePattern.RawText?.StartsWith('/')` to exclude grpc-dotnet's own
|
||||||
|
per-app "Unimplemented service" catch-all and per-service "Unimplemented method for X"
|
||||||
|
fallback (neither carries a route pattern starting with `/` in the ungrouped case). Once
|
||||||
|
LocalDbSync moved behind `app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync()`,
|
||||||
|
those SAME fallback patterns *also* gained a leading `/` inside the group, so the heuristic
|
||||||
|
started over-counting (`DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint` and
|
||||||
|
`LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical` both went red for the
|
||||||
|
wrong reason — 3 endpoints found where 1 was expected). Fixed by matching against the exact
|
||||||
|
known set of five method route-pattern literals instead of a shape heuristic. Not a plan defect
|
||||||
|
— the plan didn't specify the filter mechanism — but a genuine gotcha for anyone extending this
|
||||||
|
census: **grpc-dotnet's route-pattern-shape for its own fallback endpoints is not stable across
|
||||||
|
a plain `MapGrpcService` vs. a grouped one**, so don't filter on shape once any mesh map moves
|
||||||
|
behind a group.
|
||||||
|
|
||||||
|
2. **T4's fixture does not map or wire `LocalDbSyncService`'s real dependencies.** The plan's
|
||||||
|
fixture description says "maps the mesh services through `MapOtOpcUaMeshGrpcServices`"; T4's
|
||||||
|
`MeshGrpcAuthStackTests` calls it with `syncListenPort: 0` (LocalDbSync NOT mapped by the
|
||||||
|
helper) and instead hand-maps the library's `LocalDbSyncService` directly, with no
|
||||||
|
`.AllowAnonymous()`, purely as the negative control. This is a minimal, deliberate narrowing:
|
||||||
|
no assertion in T4 or T5 exercises LocalDbSync's real RPC (the plan's own positive/negative
|
||||||
|
lists never name it), and standing up its DI graph (`AddZbLocalDb`/`AddZbLocalDbReplication`,
|
||||||
|
a temp-file SQLite `ILocalDb`) would have added fixture weight with nothing to verify — the
|
||||||
|
authorization middleware refuses an unauthorized call before the service is ever constructed,
|
||||||
|
so the DI graph doesn't need to exist for the negative control to be meaningful. Recorded here
|
||||||
|
because it's a case where the fixture is deliberately narrower than a literal reading of the
|
||||||
|
plan's prose, even though every named assertion is still covered.
|
||||||
|
|
||||||
|
3. **The positive/negative service lists T4 and T5 assert are asymmetric, and that asymmetry is
|
||||||
|
the plan's own, not introduced here.** T4's positive list is {`DeploymentArtifactService.Fetch`,
|
||||||
|
`CentralCommandService.ReportApply`, `NodeCommandService.Execute`} — Telemetry is not asserted
|
||||||
|
positively there. T5's no-PSK detail-string list is {Config-serve, Telemetry stream, Node
|
||||||
|
command} — `CentralCommandService`'s own detail string
|
||||||
|
(`"Central command authentication failed."`) is never asserted anywhere in T4/T5. Implemented
|
||||||
|
exactly as specified (no scope widening per the run brief); flagging the gap in case a
|
||||||
|
follow-up wants a `NoOrWrongPsk_CentralCommandReportApply_FailsWithTheInterceptorsDetail` case
|
||||||
|
for full symmetry — trivial to add given the fixture already maps and keys that service.
|
||||||
|
|
||||||
|
4. **Observed cookie-challenge signature (T5 step 2), captured directly against this repo's
|
||||||
|
fixture stack (macOS, Kestrel h2c, `Grpc.Net.Client` over `HttpClient`/`SocketsHttpHandler`):**
|
||||||
|
every blocked positive-test call in the red state failed as
|
||||||
|
`RpcException(StatusCode.Internal, "Error starting gRPC call. HttpRequestException: The HTTP/2
|
||||||
|
server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1). (HttpProtocolError)
|
||||||
|
HttpProtocolException: The HTTP/2 server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR'
|
||||||
|
(0x1). (HttpProtocolError)")` — never `Unauthenticated`/`PermissionDenied`. The cookie handler's
|
||||||
|
redirect/401 breaks the prior-knowledge h2c stream before any gRPC trailers are written, so the
|
||||||
|
client sees a transport-level reset rather than a clean gRPC status. **The live gate (T10)
|
||||||
|
should grep node/central logs and browser network traces for `PROTOCOL_ERROR`, `/login`, `302`
|
||||||
|
and `401` together** — not for `401` alone, and not for a gRPC `Unauthenticated`/
|
||||||
|
`PermissionDenied` status, which this environment never actually produces for the blocked case.
|
||||||
|
(A real reverse-proxy in front of a production deployment could still surface a clean HTTP 401
|
||||||
|
instead of a stream reset, depending on how it terminates HTTP/2 — the plan's own multi-signal
|
||||||
|
grep advice already covers that; this note is the empirical confirmation of one concrete shape.)
|
||||||
|
|
||||||
|
5. **T6 passes identically on both the pre-fix and post-fix mapper (confirmed by the same
|
||||||
|
temporary-swap procedure).** Driver-only nodes never register `AddOtOpcUaAuth` at all, so
|
||||||
|
`.AllowAnonymous()` presence or absence on the mesh maps has zero effect on that shape — T6 is
|
||||||
|
a structural pin/regression-control test, not a red-then-green TDD test for a behaviour T1–T3
|
||||||
|
changed. This matches the plan's own "standard (test-only)" classification; recorded because
|
||||||
|
the run brief asked for red-before-fix confirmation to be checked (and reported) for every task,
|
||||||
|
and this is the one task where "red" never existed to begin with.
|
||||||
|
|
||||||
|
6. **T8's `docker compose config` verification ran locally on macOS (OrbStack-backed Docker
|
||||||
|
Compose v5.1.2)** — no Windows-VM `lmxopcua-fix` helper needed, consistent with the Mac-path
|
||||||
|
guidance in CLAUDE.md. Confirmed both branches: unset `OTOPCUA_DISABLE_LOGIN` resolves
|
||||||
|
`Security__Auth__DisableLogin: "true"` on both central-1/central-2; `OTOPCUA_DISABLE_LOGIN=false`
|
||||||
|
resolves `"false"` on both.
|
||||||
|
|
||||||
|
No other deviations from the plan's design or task breakdown. T10 was not run — left `pending`
|
||||||
|
per the run brief, for the orchestrator.
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
{
|
{
|
||||||
"id": "T1",
|
"id": "T1",
|
||||||
"subject": "Extract MapOtOpcUaMeshGrpcServices from Program.cs:743-784 (behaviour-preserving) + endpoint census scaffold (03/S14)",
|
"subject": "Extract MapOtOpcUaMeshGrpcServices from Program.cs:743-784 (behaviour-preserving) + endpoint census scaffold (03/S14)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T2",
|
"id": "T2",
|
||||||
"subject": "Census asserts IAllowAnonymous + scope/count guard-of-the-guard; .AllowAnonymous() on the four MapGrpcService maps",
|
"subject": "Census asserts IAllowAnonymous + scope/count guard-of-the-guard; .AllowAnonymous() on the four MapGrpcService maps",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1"
|
"T1"
|
||||||
]
|
]
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T3",
|
"id": "T3",
|
||||||
"subject": "LocalDb sync endpoint anonymised via an empty-prefix MapGroup (MapZbLocalDbSync returns IEndpointRouteBuilder, not chainable)",
|
"subject": "LocalDb sync endpoint anonymised via an empty-prefix MapGroup (MapZbLocalDbSync returns IEndpointRouteBuilder, not chainable)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T2"
|
"T2"
|
||||||
]
|
]
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T4",
|
"id": "T4",
|
||||||
"subject": "MeshGrpcAuthStackTests: PSK-bearing calls succeed through the REAL AddOtOpcUaAuth + UseAuthentication/UseAuthorization stack, with an un-anonymised negative control",
|
"subject": "MeshGrpcAuthStackTests: PSK-bearing calls succeed through the REAL AddOtOpcUaAuth + UseAuthentication/UseAuthorization stack, with an un-anonymised negative control",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T3"
|
"T3"
|
||||||
]
|
]
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T5",
|
"id": "T5",
|
||||||
"subject": "Status fidelity: a no-PSK call reports the interceptor's DETAIL string, not a cookie 401/302 (ConfigServe Unauthenticated collision makes status-only assertions vacuous)",
|
"subject": "Status fidelity: a no-PSK call reports the interceptor's DETAIL string, not a cookie 401/302 (ConfigServe Unauthenticated collision makes status-only assertions vacuous)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T4"
|
"T4"
|
||||||
]
|
]
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T6",
|
"id": "T6",
|
||||||
"subject": "MeshGrpcDriverOnlyPipelineTests: driver-only nodes carry no authorization middleware — pins the 03/S14 exemption",
|
"subject": "MeshGrpcDriverOnlyPipelineTests: driver-only nodes carry no authorization middleware — pins the 03/S14 exemption",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T3"
|
"T3"
|
||||||
]
|
]
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T7",
|
"id": "T7",
|
||||||
"subject": "MeshGrpcMapConventionTests: Program.cs source scan — zero direct MapGrpcService/MapZbLocalDbSync sites, exactly one mapper call",
|
"subject": "MeshGrpcMapConventionTests: Program.cs source scan — zero direct MapGrpcService/MapZbLocalDbSync sites, exactly one mapper call",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1"
|
"T1"
|
||||||
]
|
]
|
||||||
@@ -59,13 +59,13 @@
|
|||||||
{
|
{
|
||||||
"id": "T8",
|
"id": "T8",
|
||||||
"subject": "docker-dev: Security__Auth__DisableLogin parameterised as ${OTOPCUA_DISABLE_LOGIN:-true} on central-1/central-2 + header docs",
|
"subject": "docker-dev: Security__Auth__DisableLogin parameterised as ${OTOPCUA_DISABLE_LOGIN:-true} on central-1/central-2 + header docs",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T9",
|
"id": "T9",
|
||||||
"subject": "docs/security.md \"Mesh gRPC ingress is AllowAnonymous by design\" + cross-refs in docs/Configuration.md and docs/Telemetry.md",
|
"subject": "docs/security.md \"Mesh gRPC ingress is AllowAnonymous by design\" + cross-refs in docs/Configuration.md and docs/Telemetry.md",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -452,3 +452,321 @@ T8 anytime; T9 last (after T3/T5/T6/T7 merge-ready)
|
|||||||
```
|
```
|
||||||
|
|
||||||
Overall effort: **~0.75–1 dev-day code+unit, plus the rig session.** The one HIGH-RISK production slice (T3, which changes what every child receives at spawn) lands behind the full 25-suite `DriverHostActor` regression before any new test stacks on it, and behind T2's explicitly behaviour-preserving refactor.
|
Overall effort: **~0.75–1 dev-day code+unit, plus the rig session.** The one HIGH-RISK production slice (T3, which changes what every child receives at spawn) lands behind the full 25-suite `DriverHostActor` regression before any new test stacks on it, and behind T2's explicitly behaviour-preserving refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution notes
|
||||||
|
|
||||||
|
Branch `fix/r3-03-restart-resubscribe-watchdog`, worktree
|
||||||
|
`.claude/worktrees/agent-a5bcdd61bfba22e10`, off master `5fd65d19`. TDD throughout: every
|
||||||
|
behaviour task's test was written first, RUN, and observed red for the predicted reason before the
|
||||||
|
production edit landed. Red observations are recorded per task below.
|
||||||
|
|
||||||
|
### T1 — 03/S17 watchdog grace (`??` → max)
|
||||||
|
|
||||||
|
**RED observed (as predicted).** With the two new tests in place against unmodified
|
||||||
|
`DriverInstanceActor`:
|
||||||
|
|
||||||
|
```
|
||||||
|
DriverInstanceActorCommsLossWatchdogTests.A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again [FAIL]
|
||||||
|
Failed: Expected no messages during 00:00:00.4000000, instead we received
|
||||||
|
<ConnectivityChanged { DriverInstanceId = stub-driver-1, Connected = False }> after 00:00:00.0496590
|
||||||
|
Failed! - Failed: 1, Passed: 9, Total: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
The re-trip landed **49.7 ms** after the reconnect — one health tick (`FastPoll` = 50 ms) — against a
|
||||||
|
`Grace` of 1500 ms, i.e. exactly the "churn at health-tick cadence instead of the designed grace"
|
||||||
|
the finding describes. `A_half_broken_device_is_still_declared_lost_once_the_grace_elapses` passed
|
||||||
|
in the same run, which is what makes it the anti-vacuity control: it cannot distinguish the fix from
|
||||||
|
the bug, so the delay assertion is the one carrying the finding.
|
||||||
|
|
||||||
|
**GREEN after the one-line max at `:574`** (plus the "grace is per connection" remarks paragraph and
|
||||||
|
the reciprocal falsification-partner xmldoc on the existing
|
||||||
|
`A_degraded_driver_that_is_still_reading_successfully_is_left_alone`): 10/10 passed, 20 s.
|
||||||
|
`A_reconnect_attempt_tears_the_dead_transport_down_before_re_initialising` stayed green — as the plan
|
||||||
|
anticipated, each of its trip cycles now costs a full `FastTimeout` (250 ms) instead of one 50 ms
|
||||||
|
tick, and its `AwaitCondition(InitializeCount >= 2, 5 s)` still has ample headroom. No budget change
|
||||||
|
was needed.
|
||||||
|
|
||||||
|
### T2 — desired-subscription payload authority
|
||||||
|
|
||||||
|
Behaviour-preserving refactor, so there is no red observation to record by design — the gate is that
|
||||||
|
**no test moves**. `--filter "FullyQualifiedName~DriverHostActor"` → **114/114 passed**, unchanged
|
||||||
|
from the base commit.
|
||||||
|
|
||||||
|
⚠️ One run of that filter reported `DriverHostActorNativeAlarmAckRoutingTests.RouteNativeAlarmAck_on_non_primary_is_dropped`
|
||||||
|
failing on an `AwaitAssert`. Re-run in isolation: **3/3 passed**; re-run of the whole 114-test filter:
|
||||||
|
**114/114 passed**. Treated as the known #500 flake set for this assembly (~13 %/run), not a
|
||||||
|
regression — it touches none of the code this task edits (the alarm-ack Primary gate is a different
|
||||||
|
method entirely).
|
||||||
|
|
||||||
|
Deviation from the plan's literal step text (cosmetic, recorded for the reviewer): the plan's Finding-1
|
||||||
|
step 1 says `_desiredByDriver` *replaces* `_lastComposition` at `:347-349` while T3 step 2 says T3
|
||||||
|
deletes `_lastComposition`. Keeping T2 compilable means the two fields coexist for one commit;
|
||||||
|
`_lastComposition` and its assignment are deleted in T3 as T3's own step text specifies.
|
||||||
|
|
||||||
|
### T3 — `SpawnChild` always sends the desired set; `_lastComposition` deleted
|
||||||
|
|
||||||
|
The falsification for this production slice is **T5**, written and observed red afterwards (below) —
|
||||||
|
T3 lands the fix first because the plan sequences it that way and the ordering guard needs the whole
|
||||||
|
25-suite regression before any new test is trusted on top of it.
|
||||||
|
|
||||||
|
**HIGH-RISK regression gate (every child's spawn-time state changes, deploy path included):** the
|
||||||
|
FULL `Runtime.Tests` project, not just the `DriverHostActor` filter —
|
||||||
|
**639 passed, 3 skipped, 0 failed, 55 s**. The 3 skips are pre-existing (`DeploymentArtifact*` parity
|
||||||
|
tests, skipped on the base commit too).
|
||||||
|
|
||||||
|
`_lastComposition` is gone: `grep -c _lastComposition DriverHostActor.cs` → **0**. Its xmldoc had also
|
||||||
|
drifted (it claimed the field was set at the end of `PushDesiredSubscriptions`; the assignment was in
|
||||||
|
`ApplyDesiredSubscriptions`) — deleted with the field rather than corrected. The stale
|
||||||
|
"dropped-driver fallback" comment on the old `SendAuthoredOnly` local function was rewritten as the
|
||||||
|
`SendDesiredSubscriptions` remarks naming the three real consumers.
|
||||||
|
|
||||||
|
### T4 — test harness (seeder `enabled` flag + recording driver factory)
|
||||||
|
|
||||||
|
Test-only, additive, source-compatible: every pre-existing `SeedV3Deployment` / `SpawnHostAndApply`
|
||||||
|
call site compiles and behaves identically (the new parameters are trailing and default to today's
|
||||||
|
values). Gate: `--filter "…~DriverHostActor|…~DriverInstanceActor"` → **179/179 passed** (the plan
|
||||||
|
named `--filter "…~V3"`, which matches nothing — the seeder's four consumers are all `DriverHostActor*`
|
||||||
|
classes; the wider filter is the same set plus more).
|
||||||
|
|
||||||
|
**Deviation (recorded).** The plan lists one new seeder parameter (`enabled`); a second was needed and
|
||||||
|
added: `IReadOnlyDictionary<string, string>? tagConfigByTag = null`. The seeder hard-codes
|
||||||
|
`TagConfig = "{}"` on every tag, and a raw tag's alarm is derived *only* from `TagConfig.alarm` via
|
||||||
|
`TagConfigIntent.Parse` (`AddressSpaceComposer.cs:769-772`) — so without it there is no way to seed the
|
||||||
|
alarm-bearing raw tag that `Operator_restart_re_subscribes_alarm_refs_too` (a test the plan itself
|
||||||
|
specifies) requires. It is trailing and optional, so it is the same source-compatible shape as
|
||||||
|
`enabled`.
|
||||||
|
|
||||||
|
### T5 — `DriverHostActorRestartTests` (operator-restart falsification, 03/U6's first half)
|
||||||
|
|
||||||
|
**RED observed against a T3-reverted build** (the `SendDesiredSubscriptions(child, …)` call in
|
||||||
|
`SpawnChild` commented out, everything else on the branch): **all 4 FAILED, 0 passed**, each at the
|
||||||
|
bar the plan predicts —
|
||||||
|
|
||||||
|
```
|
||||||
|
Operator_restart_re_subscribes_the_respawned_child [FAIL]
|
||||||
|
factory.Created[1].SubscribeCount should be greater than or equal to 1 but was 0
|
||||||
|
Operator_restart_re_subscribes_alarm_refs_too [FAIL]
|
||||||
|
factory.Created[1].SubscribeAlarmsCount should be greater than or equal to 1 but was 0
|
||||||
|
Restart_of_a_driver_with_an_empty_value_set_still_sends_the_set [FAIL]
|
||||||
|
respawned.SubscribeAlarmsCount should be greater than or equal to 1 but was 0
|
||||||
|
A_child_spawned_by_a_fresh_deploy_ends_with_the_NEW_refs [FAIL]
|
||||||
|
factory.Created[2].LastSubscribedRefs should be ["Plant/Modbus/dev1/temp"] but was null
|
||||||
|
```
|
||||||
|
|
||||||
|
The call was then restored; **4/4 pass**, and the class was run **20× in isolation, 20/20 green** (#500
|
||||||
|
discipline for this assembly).
|
||||||
|
|
||||||
|
**Two deviations, both found by running the tests rather than by reading:**
|
||||||
|
|
||||||
|
1. ⚠️ **The plan's claim that the transient spawn-time set "is never acted on" is FALSE, and the
|
||||||
|
production comment that repeated it has been corrected.** The plan reasons that the child is still
|
||||||
|
`Connecting` when the stale set arrives, so it is only stored. But the child runs **concurrently**
|
||||||
|
with the rest of `HandleArtifactForApply` — `Context.ActorOf` returns and the child starts
|
||||||
|
processing while the host is still building `refsByDriver` — so a driver whose `InitializeAsync`
|
||||||
|
completes instantly reaches Connected and subscribes the stale set *before* the bulk push arrives.
|
||||||
|
Measured directly: `SubscribeCount == 2` on the respawned child, first with revision A's ref and
|
||||||
|
then with B's. What Akka's per-pair ordering does guarantee is **convergence**, and
|
||||||
|
`HandleSubscribeAsync` drops the prior subscription before establishing the new one, so the cost is
|
||||||
|
one extra subscribe round-trip on a *respawning* deploy — a real device driver, whose initialise
|
||||||
|
opens a socket, rarely wins that race. Accepted rather than "fixed": withholding the spawn-time send
|
||||||
|
is the 03/S16 defect, and the operator-Restart and supervision paths have no bulk push coming at
|
||||||
|
all. The `SpawnChild` comment now states this instead of the plan's claim, and the test asserts
|
||||||
|
convergence (the plan's own stated acceptance — "the child's live subscription carries the NEW
|
||||||
|
refs") rather than pinning the race.
|
||||||
|
2. **`A_child_spawned_by_a_fresh_deploy_ends_with_the_NEW_refs` had to be re-shaped.** The plan
|
||||||
|
describes revision B as one "which respawns nothing but re-pushes". It respawns: the raw tags are
|
||||||
|
folded into the merged `DriverConfig`, so a tag-set edit changes the `DriverInstanceSpec` and trips
|
||||||
|
the #516 config respawn. That is the *stronger* arrangement for this test's subject (a brand-new
|
||||||
|
child spawned while the authority still holds A), so the test keeps the same name and asserts the
|
||||||
|
respawned child converges on B — plus a subsequent operator Restart, which pins that the authority
|
||||||
|
is repopulated **whole** per apply rather than accumulated.
|
||||||
|
3. **`Restart_of_a_driver_with_no_authored_tags_sends_an_empty_set_not_nothing` was renamed
|
||||||
|
`Restart_of_a_driver_with_an_empty_value_set_still_sends_the_set`, with a real positive control.**
|
||||||
|
The seeder derives `DriverInstances` from the raw-tag rows, so a driver with *zero* tags is not
|
||||||
|
expressible, and — more importantly — an all-empty set has **no driver-side observable at all**
|
||||||
|
(the child stores it and, having no handle, does nothing), which would have made the test vacuous:
|
||||||
|
"it did not subscribe" passes equally against a build that sends the respawned child nothing, i.e.
|
||||||
|
against the defect itself. The subject is therefore a driver whose only raw tag is alarm-bearing:
|
||||||
|
its value half is genuinely empty while its alarm half is not, and **both halves ride in one
|
||||||
|
message** — so `SubscribeAlarmsCount >= 1` proves the message ARRIVED and `SubscribeCount == 0`
|
||||||
|
then proves the empty value half was honoured. The falsification run above confirms the control
|
||||||
|
works: with T3 reverted this test fails on the *positive control*, not on the absence.
|
||||||
|
**Not covered, and worth a reviewer's eye:** the `_desiredByDriver` union's `_children.Keys` arm
|
||||||
|
(a running child absent from BOTH ref maps) is not reachable through this seeder and is untested.
|
||||||
|
|
||||||
|
### T6 — supervision reprovision hook
|
||||||
|
|
||||||
|
`DriverInstanceActor.PostRestart` (calling `base.PostRestart(reason)` first, so `PreStart` re-attaches
|
||||||
|
the rediscovery / host-status sources and restarts the health timer) → `ReprovisionRequested` →
|
||||||
|
`DriverHostActor.HandleReprovisionRequested`, registered in **all three** `Receive` blocks that already
|
||||||
|
accept `RestartDriver`. The falsification is T7, below.
|
||||||
|
|
||||||
|
Gate: `--filter "…~DriverHostActor|…~DriverInstanceActor"` → **183/183 passed** (179 before this task
|
||||||
|
plus T5's four), no test moved.
|
||||||
|
|
||||||
|
### T7 — first supervision-restart test + the force-reconnect verified negative (03/U6)
|
||||||
|
|
||||||
|
**Crash seam that landed: the planned one** — `RecordingSubscribableDriver.FireMalformedDataChange`
|
||||||
|
raises `OnDataChange` with a null `Snapshot`; `DriverInstanceActor.OnDataChangeForward` dereferences
|
||||||
|
`msg.Snapshot.SourceTimestampUtc` on the actor thread and NREs; Akka's default decider Restarts. No
|
||||||
|
fallback (`OneForOneStrategy` + an internal test message) was needed, so no production surface was
|
||||||
|
added for the test.
|
||||||
|
|
||||||
|
**RED observed against a T6-reverted build** (the `Context.Parent.Tell(new ReprovisionRequested(…))`
|
||||||
|
in `PostRestart` commented out):
|
||||||
|
|
||||||
|
```
|
||||||
|
Supervision_restart_reinitialises_and_re_subscribes_the_child [FAIL]
|
||||||
|
driver.InitializeCount should be 2 but was 1
|
||||||
|
"the restarted child was never re-initialised — it is sitting in Connecting around a driver
|
||||||
|
its own PostStop already shut down (03/S6, the worse half of 03/S16)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deviation, and the reason for it — the first red run was AMBIGUOUS and the test was strengthened.**
|
||||||
|
`InitializeCount == 1` is equally consistent with "the child restarted and was not re-provisioned"
|
||||||
|
and with "the child never crashed at all", so a seam that silently stopped working would read as a
|
||||||
|
passing regression test forever. A preceding `AwaitAssert(ShutdownCount >= 1)` was added — `PreRestart`
|
||||||
|
runs `PostStop`, which calls `ShutdownAsync`, so that is the evidence a restart genuinely happened.
|
||||||
|
On the re-run of the falsification that assertion **passed** and the failure landed squarely on the
|
||||||
|
re-initialise, which is what makes the red observation above meaningful.
|
||||||
|
|
||||||
|
`Force_reconnect_keeps_the_desired_set_and_re_subscribes` passed in **both** the red and green runs —
|
||||||
|
correct, and the point: it is the verified negative (`ForceReconnect` reaches the same live actor, so
|
||||||
|
`_desiredRefs` survive and the reconnect's Connected entry re-applies them). It carries a 30 s budget
|
||||||
|
rather than `PresenceBudget`, because `SpawnChild` gives the child the production
|
||||||
|
`DefaultReconnectInterval` (10 s) and the retry is a periodic timer, so the reconnect cannot land
|
||||||
|
sooner; a budget is only spent on failure.
|
||||||
|
|
||||||
|
After restoring the hook: **6/6 pass**, and the class was run **20× in isolation, 20/20 green**.
|
||||||
|
|
||||||
|
### T8 — docs
|
||||||
|
|
||||||
|
`docs/DriverLifecycle.md`:
|
||||||
|
|
||||||
|
- New §"What a (re)spawn re-provisions" (placed before §IDriverHealthPublisher): `_desiredByDriver` as
|
||||||
|
the single authority, a table of the three incarnation routes plus the verified-negative Force
|
||||||
|
Reconnect, the supervision route's invisibility, why replacing the default decider is out of scope,
|
||||||
|
the respawning-deploy ordering **with the measured caveat that the transient is not inert**, and the
|
||||||
|
assert-a-function-not-existence rule naming the weaker existing test.
|
||||||
|
- §"The comms-loss watchdog": conjunct 2 restated as the max, plus a new paragraph on the
|
||||||
|
per-connection grace — why `??` was wrong, that it **paces** rather than ends the half-broken-device
|
||||||
|
cycle, that the watchdog is not disabled by the change, and the two tests that pin each half.
|
||||||
|
|
||||||
|
### Final verification
|
||||||
|
|
||||||
|
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 errors** (904 warnings, all pre-existing analyzer
|
||||||
|
`OTOPCUA0001` noise in unrelated driver test projects).
|
||||||
|
- **Full `Runtime.Tests`: 645 passed, 3 skipped, 0 failed** (1 m 9 s). Baseline on the branch point was
|
||||||
|
637 + 3 skipped; the +8 are this plan's new tests (T1 ×2, T5 ×4, T7 ×2). The 3 skips are pre-existing
|
||||||
|
`DeploymentArtifact*` parity tests.
|
||||||
|
- The two other test projects that reference `ZB.MOM.WW.OtOpcUa.Runtime` were run as well, since T3
|
||||||
|
changes what every driver child receives at spawn: `ControlPlane.Tests` **262/262**, `Host.Tests`
|
||||||
|
**106/106**.
|
||||||
|
- T9 (live gate) deliberately left **pending** — run by the orchestrator.
|
||||||
|
|
||||||
|
### Summary of deviations (for the reviewer)
|
||||||
|
|
||||||
|
1. **The plan's "the transient spawn-time set is never acted on" is false** — measured, corrected in
|
||||||
|
both the production comment and the docs, and accepted with its cost stated (T5, deviation 1). This
|
||||||
|
is the one behavioural claim in the plan that did not survive contact with the code.
|
||||||
|
2. `A_child_spawned_by_a_fresh_deploy_ends_with_the_NEW_refs` re-shaped — revision B *does* respawn the
|
||||||
|
child (raw tags fold into the merged `DriverConfig`) (T5, deviation 2).
|
||||||
|
3. `Restart_of_a_driver_with_no_authored_tags_sends_an_empty_set_not_nothing` renamed +
|
||||||
|
re-based on an alarm-only driver, because an all-empty set has no driver-side observable and the
|
||||||
|
planned test would have been vacuous; the `_children.Keys` union arm remains untested (T5,
|
||||||
|
deviation 3).
|
||||||
|
4. Seeder gained a second optional parameter (`tagConfigByTag`) — the only way to seed an alarm-bearing
|
||||||
|
raw tag, which one of the plan's own tests needs (T4).
|
||||||
|
5. The supervision test gained a preceding `ShutdownCount >= 1` assertion, because the plan's red
|
||||||
|
observation was ambiguous with "the child never crashed" (T7).
|
||||||
|
6. `Force_reconnect_keeps_the_desired_set_and_re_subscribes` uses a 30 s budget, not `PresenceBudget` —
|
||||||
|
`SpawnChild` gives the child the production 10 s `DefaultReconnectInterval` (T7).
|
||||||
|
7. `_lastComposition` and `_desiredByDriver` coexist for one commit (T2) so T2 stays compilable; the
|
||||||
|
deletion lands in T3, as T3's own step text specifies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review outcome
|
||||||
|
|
||||||
|
Verdict **FIX-FIRST**, 3 items. All three confirmed against the code, fixed TDD (test first, seen red
|
||||||
|
for the predicted reason, then fixed), one commit each.
|
||||||
|
|
||||||
|
### F1 [HIGH regression] — a changed alarm set latched (`edd01fcc`)
|
||||||
|
|
||||||
|
**Confirmed.** `SubscribeDesiredAlarms` and `HandleSubscribeAlarmsAsync` both gated on
|
||||||
|
`_alarmSubscriptionHandle is not null`, which is the right guard for an idempotent re-push and the wrong
|
||||||
|
one for a deploy that changes the set. The value side converges by construction
|
||||||
|
(`StoreDesiredSubscriptions` is a full replace; `HandleSubscribeAsync` drops the prior subscription
|
||||||
|
first) — the alarm side did not. **T3 is what made it reachable**: a tag-set edit forces a respawn, and
|
||||||
|
a fast-initialising driver subscribes the transient spawn-time set before the bulk push, so the
|
||||||
|
PREVIOUS revision's alarm refs latched for the connection's life. `sourceNodeIds`-filtering drivers
|
||||||
|
(OPC UA Client, AB CIP, FOCAS) would silently never deliver the newly-added alarm.
|
||||||
|
|
||||||
|
**RED:** `A_deploy_that_widens_the_alarm_set_re_subscribes_the_new_refs` — after deploying
|
||||||
|
alarm `{alarmX}` then `{alarmX, alarmY}`, `LastSubscribedAlarmRefs` was still `["Plant/Modbus/dev1/alarmX"]`.
|
||||||
|
|
||||||
|
**Fix:** new `_subscribedAlarmRefs` records what the live handle was established with;
|
||||||
|
`AlarmSubscriptionIsStale()` (no handle **or** the desired refs differ) replaces the bare null check in
|
||||||
|
both places; a stale handle is **released before** re-subscribing so a filtering driver cannot
|
||||||
|
accumulate two overlapping subscriptions. The release clears the handle *first*, so a throwing
|
||||||
|
unsubscribe cannot leave the staleness check treating a dead handle as live, and it is best-effort
|
||||||
|
(logged, non-fatal — the re-subscribe is what matters). Unsubscribe-on-**empty** is still deliberately
|
||||||
|
absent, unchanged. Two notes: the release had to be wrapped in `_invoker.ExecuteAsync` or the repo's
|
||||||
|
`OTOPCUA0001` analyzer fails the build; and the Connected-handler comment that stated the
|
||||||
|
value-only convergence claim was corrected, as was the matching bullet in `docs/DriverLifecycle.md`.
|
||||||
|
|
||||||
|
### F2 [MEDIUM] — reprovision routed by id, not incarnation (`2b7100cf`)
|
||||||
|
|
||||||
|
**Confirmed.** Fixed with `if (!Equals(Sender, entry.Actor)) return;` (plus a Debug line naming the
|
||||||
|
superseded ref).
|
||||||
|
|
||||||
|
⚠️ **The first version of the test PASSED against the unfixed code — the arrangement mattered.** With
|
||||||
|
the child already **Connected**, `InitializeRequested` is unhandled, so the stale reprovision was
|
||||||
|
swallowed for the wrong reason and the test was vacuous. The defect only bites a child in
|
||||||
|
`Connecting`, which is exactly where `InitializeRequested` *is* handled. `RecordingSubscribableDriver`
|
||||||
|
therefore gained an `InitializeGate` (a `TaskCompletionSource` its `InitializeAsync` awaits) so the
|
||||||
|
test can hold the child in `Connecting`. **RED with that arrangement:** `InitializeCount == 2` — the
|
||||||
|
second concurrent `InitializeAsync` racing the #527 dispose-before-overwrite. The absence assertion
|
||||||
|
carries a positive control (the child is proven alive at `InitializeCount == 1` first) and a
|
||||||
|
`GetDiagnostics` round-trip so the dropped message is provably processed before the absence is read.
|
||||||
|
|
||||||
|
### F3 [IMPORTANT] — no backoff on the reprovision (`0793b0cb`)
|
||||||
|
|
||||||
|
**Confirmed.** Added `DefaultReprovisionBackoff` (10 s, matched to
|
||||||
|
`DriverInstanceActor.DefaultReconnectInterval` because the reprovision drives the same connect work),
|
||||||
|
a per-driver `_lastReprovisionUtc` stamp, and a `reprovisionBackoff` Props parameter following the
|
||||||
|
existing `applyWedgeTimeout` precedent so a test need not wait the production window.
|
||||||
|
|
||||||
|
A held request is **deferred, not dropped**: one `StartSingleTimer` per driver re-delivers a
|
||||||
|
`ReprovisionDeferred` carrying the **original incarnation ref**, so F2's identity check still applies
|
||||||
|
on re-delivery, and a burst collapses to a single pending reprovision rather than a queue. Each
|
||||||
|
deferral logs a Warning naming the crash type, so a crash loop is visible rather than quiet.
|
||||||
|
|
||||||
|
**RED** (backoff disabled): `InitializeCount == 3` where the test requires 2-then-3 — i.e. the second
|
||||||
|
reprovision applied immediately. The test drives **two genuine supervision restarts** (not synthetic
|
||||||
|
messages), waits for the re-subscribe before arming the second crash, and asserts `ShutdownCount >= 2`
|
||||||
|
so "the second crash tripped supervision" is proven before the backoff is read.
|
||||||
|
|
||||||
|
### Optional adds (both done)
|
||||||
|
|
||||||
|
- `A_running_driver_with_no_authored_tags_is_handed_an_empty_set` — the `_children.Keys` union arm,
|
||||||
|
previously untested and called out as a gap in the T5 notes. It needed a driver with no tags at all,
|
||||||
|
which the seeder could not express, so `SeedV3Deployment` gained `taglessDrivers`. The tagless child
|
||||||
|
is asserted initialised-but-never-subscribed, with its tag-owning sibling's subscribe as the positive
|
||||||
|
control that the push pass ran.
|
||||||
|
- `_desiredByDriver` xmldoc now records that it is rebuilt only by a **successful** apply, so it
|
||||||
|
lingers on the last-known-good composition after a failed one — consistent with the address space,
|
||||||
|
drivers and subscriptions that also keep serving (#485/#486).
|
||||||
|
|
||||||
|
### Verification after the three fixes
|
||||||
|
|
||||||
|
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 errors**.
|
||||||
|
- **Full `Runtime.Tests`: 649 passed, 3 skipped, 0 failed** (up 4 from 645 — F1, F2, F3 and the
|
||||||
|
union-arm test).
|
||||||
|
- **`ControlPlane.Tests`: 262/262.**
|
||||||
|
- `DriverHostActorRestartTests` (now 10 cases) run **10× in isolation, 10/10 green**.
|
||||||
|
- T9 (live gate) still **pending** — the orchestrator runs it. Worth noting for that session: F1 is
|
||||||
|
directly observable on the rig, by deploying a second alarm-bearing raw tag to a driver that already
|
||||||
|
has one and confirming a transition on the NEW tag reaches `/alerts`.
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
{
|
{
|
||||||
"id": "T1",
|
"id": "T1",
|
||||||
"subject": "Comms-loss grace is per connection: ?? -> max(LastSuccessfulRead, _connectedSinceUtc) + falsification pair (03/S17)",
|
"subject": "Comms-loss grace is per connection: ?? -> max(LastSuccessfulRead, _connectedSinceUtc) + falsification pair (03/S17)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T2",
|
"id": "T2",
|
||||||
"subject": "Desired-subscription payload authority: _desiredByDriver + SendDesiredSubscriptions (behaviour-preserving refactor)",
|
"subject": "Desired-subscription payload authority: _desiredByDriver + SendDesiredSubscriptions (behaviour-preserving refactor)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T3",
|
"id": "T3",
|
||||||
"subject": "SpawnChild always sends the desired set; delete the write-only _lastComposition (03/S16)",
|
"subject": "SpawnChild always sends the desired set; delete the write-only _lastComposition (03/S16)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T2"
|
"T2"
|
||||||
]
|
]
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
{
|
{
|
||||||
"id": "T4",
|
"id": "T4",
|
||||||
"subject": "Test harness: V3ArtifactSeeder enabled flag + RecordingSubscribableDriver(Factory) with a crash seam",
|
"subject": "Test harness: V3ArtifactSeeder enabled flag + RecordingSubscribableDriver(Factory) with a crash seam",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T5",
|
"id": "T5",
|
||||||
"subject": "DriverHostActorRestartTests: operator restart re-subscribes the respawned child (falsifiable; 03/S16, U6)",
|
"subject": "DriverHostActorRestartTests: operator restart re-subscribes the respawned child (falsifiable; 03/S16, U6)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T3",
|
"T3",
|
||||||
"T4"
|
"T4"
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T6",
|
"id": "T6",
|
||||||
"subject": "Supervision reprovision hook: DriverInstanceActor.PostRestart -> ReprovisionRequested -> host re-init + re-subscribe (03/S6, S16)",
|
"subject": "Supervision reprovision hook: DriverInstanceActor.PostRestart -> ReprovisionRequested -> host re-init + re-subscribe (03/S6, S16)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T3"
|
"T3"
|
||||||
]
|
]
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T7",
|
"id": "T7",
|
||||||
"subject": "First real supervision-restart test + force-reconnect verified-negative regression (03/U6)",
|
"subject": "First real supervision-restart test + force-reconnect verified-negative regression (03/U6)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T4",
|
"T4",
|
||||||
"T6"
|
"T6"
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T8",
|
"id": "T8",
|
||||||
"subject": "docs/DriverLifecycle.md: what a (re)spawn re-provisions + per-connection watchdog grace",
|
"subject": "docs/DriverLifecycle.md: what a (re)spawn re-provisions + per-connection watchdog grace",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -578,3 +578,242 @@ T10 anytime after T5/T8; T11 last (after T5 + T9 merge-ready); T12 anytime
|
|||||||
```
|
```
|
||||||
|
|
||||||
Overall effort: **~1 dev-day code+unit, plus the rig session.** The two HIGH-RISK slices are T2/T3 (authentication path — every new branch must land on deny) and T11 (the gate that proves the clock axis actually reaches a real client). T9 is the largest single task and is the only one that can falsify T8 end to end.
|
Overall effort: **~1 dev-day code+unit, plus the rig session.** The two HIGH-RISK slices are T2/T3 (authentication path — every new branch must land on deny) and T11 (the gate that proves the clock axis actually reaches a real client). T9 is the largest single task and is the only one that can falsify T8 end to end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution notes
|
||||||
|
|
||||||
|
Branch `fix/r3-05-acl-hot-path-expiry`, executed 2026-08-20 off master `dd7f4954`. TDD per task:
|
||||||
|
failing test first, red observed and recorded, then green. T11 (live gate) deliberately left `pending`
|
||||||
|
for the orchestrator.
|
||||||
|
|
||||||
|
### T1 — TimeProvider registration + option knobs
|
||||||
|
|
||||||
|
- **RED observed:** `error CS1061: 'ServiceCollection' does not contain a definition for
|
||||||
|
'AddOtOpcUaDataPlaneAuth'` and `'DataPlaneAuthorizer' does not contain a definition for 'Clock'` —
|
||||||
|
the seam and the assertion surface both absent, as predicted.
|
||||||
|
- **DEVIATION (structural, required by the plan's own test):** the plan says the guard test "builds the
|
||||||
|
service collection for an admin node". `Program.cs` is **top-level statements in an executable**, so no
|
||||||
|
test can drive its wiring. The `DataPlaneAuthorizer` registration and the three-branch key-store /
|
||||||
|
authenticator conditional were therefore **moved verbatim** into a new
|
||||||
|
`Host/Configuration/DataPlaneAuthRegistration.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver)` — the same
|
||||||
|
shape `LocalDbRegistration` already uses — and `Program.cs` now calls it. No branch changed.
|
||||||
|
- **DEVIATION (small):** `DataPlaneAuthorizer` gained a public `Clock` property. The two-clock hazard the
|
||||||
|
class already documents can only be pinned by reference, and `_time` was private.
|
||||||
|
- **Collateral:** `MeshRegistrationRoleScopingTests.HasAdminMarkerScan_MatchesTheKnownShapeOfProgramCs`
|
||||||
|
pins the `if (hasAdmin)` marker count in `Program.cs`; the move took one braced marker with it, so the
|
||||||
|
pin went 6 → 5 (the test's own message asks for exactly that when a marker moves).
|
||||||
|
|
||||||
|
### T2 — `BoundedApiKeyStore` deadline
|
||||||
|
|
||||||
|
- **RED observed:** `error CS0246: The type or namespace name 'BoundedApiKeyStore' could not be found` —
|
||||||
|
type absent, as predicted.
|
||||||
|
- **DEVIATION (API):** the plan's snippet calls `cts.CancelAfter(TimeSpan, TimeProvider)`. **No such
|
||||||
|
overload exists** (`error CS1501: No overload for method 'CancelAfter' takes 2 arguments`). The
|
||||||
|
clock-aware equivalent is the `CancellationTokenSource(TimeSpan, TimeProvider)` **constructor**, linked
|
||||||
|
with the caller's token — same behaviour, same injected clock, and the linked source still lets the
|
||||||
|
`when (!ct.IsCancellationRequested)` filter tell a deadline from a caller cancellation.
|
||||||
|
- Added beyond the plan's list: `A_zero_timeout_disables_the_deadline` (the documented rollback, and the
|
||||||
|
in-class control proving the *bound* is what denies rather than the fake) and
|
||||||
|
`Anonymous_grant_resolution_is_bounded_too` (moved forward from the plan's T3 list — the deadline half
|
||||||
|
reaches `FindGrantsAsync` in the same change, so it is asserted in the same change).
|
||||||
|
- 5 tests, green.
|
||||||
|
|
||||||
|
### T3 — TTL read-through cache + the falsification control
|
||||||
|
|
||||||
|
- **RED observed:** `error CS0117: 'BoundedApiKeyStore' does not contain a definition for
|
||||||
|
'MaxCacheEntries'` — the cache and its ceiling absent.
|
||||||
|
- **DEVIATION (fail-closed refinement, 2 lines):** the plan keys `FindAsync`'s cache on `apiKeyId` alone.
|
||||||
|
Taken literally that lets the cache **resurrect a key that expired by the clock** for up to a TTL, which
|
||||||
|
is exactly the axis 03/S19 exists to close. A cached hit is therefore re-checked against the caller's
|
||||||
|
`utcNow` before it is served (`IsExpired`) — so the TTL bounds how stale an `Enabled` **revocation** may
|
||||||
|
be, and nothing else. `utcNow` is still not part of the key (it changes every call and would defeat the
|
||||||
|
cache entirely).
|
||||||
|
- The counter's `cache_hit` / `timeout` `Add` calls are deliberately NOT here — they are T4, and writing
|
||||||
|
them early would have made T4's red vacuous.
|
||||||
|
- 11 tests in the class, green.
|
||||||
|
|
||||||
|
### T4 — `otopcua.opcua.apikey_store.bounded` counter
|
||||||
|
|
||||||
|
- **RED observed (behavioural, not compile):** `A_timeout_meters_outcome_timeout` and
|
||||||
|
`A_cache_hit_meters_outcome_cache_hit` both `[FAIL]` with `should be` — the instrument existed nowhere,
|
||||||
|
so the listener saw nothing. `A_plain_read_through_meters_neither` passed vacuously at red, by design:
|
||||||
|
its job starts once the counter exists.
|
||||||
|
- **NOTE on the metrics-leak memory:** the recorder filters on **instrument name only**. That is normally
|
||||||
|
the leak (a parallel test's measurements land in it), and it is safe here for a checkable reason
|
||||||
|
recorded on the helper: `BoundedApiKeyStore` is the only writer of this instrument, only this class
|
||||||
|
constructs one, and xUnit never runs two tests of one class in parallel. Adding a second class that
|
||||||
|
exercises the decorator means adding a discriminating tag.
|
||||||
|
- 14 tests in the class, green.
|
||||||
|
|
||||||
|
### T5 — wire the decorator + registration guard
|
||||||
|
|
||||||
|
- **RED observed:** `error CS1061: 'BoundedApiKeyStore' does not contain a definition for 'Inner'` /
|
||||||
|
`… for 'Clock'` — the guard could not see inside the wrapper because nothing was wrapping yet.
|
||||||
|
- **DEVIATION (small):** `BoundedApiKeyStore` gained public `Inner` and `Clock` properties, for the same
|
||||||
|
reason `DataPlaneAuthorizer.Clock` did in T1 — a guard that cannot see what is inside the wrapper cannot
|
||||||
|
tell a correctly-wired node from one wrapping the wrong store.
|
||||||
|
- Doc-drift half of the finding landed here: the authenticator's "no network I/O on this path at all"
|
||||||
|
remark is now qualified by node role and points at the bound, and `ConfigDbApiKeyStore` records both
|
||||||
|
that it is wrapped in production and that "revocation on the next request" is **activation**-scoped.
|
||||||
|
- Host.Tests: 123 green (was 106 at base).
|
||||||
|
|
||||||
|
### T6 — `AclRevalidationTimer`
|
||||||
|
|
||||||
|
- **RED observed:** `error CS0246: The type or namespace name 'AclRevalidationTimer' could not be found`.
|
||||||
|
- **DEVIATION (visibility):** the plan declares the class `internal sealed`. `ZB.MOM.WW.OtOpcUa.Host` has
|
||||||
|
**no `InternalsVisibleTo`**, so the tests could not reach it. Made `public sealed`, matching
|
||||||
|
`BoundedApiKeyStore` and every other type in `Host/OpcUa/`.
|
||||||
|
- Added beyond the plan's list: `A_tick_runs_the_pass` — the control for the two "nothing happened" cases
|
||||||
|
(a `Tick()` that silently did nothing would satisfy the throwing-pass and dispose cases on its own).
|
||||||
|
- 5 tests, green. Also corrected the T4 `MeterRecorder` remark: the assembly declares
|
||||||
|
`CollectionBehavior(DisableTestParallelization = true)`, which is a stronger and simpler reason the
|
||||||
|
name-only filter is safe than the one first written.
|
||||||
|
|
||||||
|
### T7 — node-manager expiry tests + xmldoc correction
|
||||||
|
|
||||||
|
- **RED: the three new cases PASSED on first run — as the plan predicted** ("the expiry case may already
|
||||||
|
PASS … that is the point: it pins the property the timer will rely on before the timer exists"). The
|
||||||
|
pass already reads the authorizer's `TimeProvider`, so no production change was needed.
|
||||||
|
- **Falsification run (recorded, since "it passed immediately" proves nothing on its own):** shortening the
|
||||||
|
clock advance in `An_expired_key_stands_down_a_live_item_with_no_deploy` from 20 min to 5 min (i.e.
|
||||||
|
short of `ExpiresUtc`) turned it **`[FAIL]`**, then restored. So the assertion tracks the clock rather
|
||||||
|
than a blanket deny.
|
||||||
|
- `A_renewed_key_re_attaches_the_item_on_the_next_pass` observes the restore **indirectly and
|
||||||
|
falsifiably**: the pass skips an already-suppressed item, so standing the item down a second time can
|
||||||
|
only succeed if it was genuinely re-attached in between. (`NodeHandle.MonitoredNode` is null on a
|
||||||
|
hand-tracked stub item, so attach/detach is not directly observable at this seam — it is proven over the
|
||||||
|
wire in T9.)
|
||||||
|
- Two stale remarks corrected, not one: `OnAuthorityChanged`'s "this is the only trigger" **and**
|
||||||
|
`IsSubscriptionStillPermitted`'s "a subscription is re-checked at a deployment, never on a timer".
|
||||||
|
- OpcUaServer.Tests: 493 passed / 1 skipped.
|
||||||
|
|
||||||
|
### T8 — wire the timer into `OtOpcUaServerHostedService`
|
||||||
|
|
||||||
|
- **RED observed:** `error CS1061: 'OtOpcUaServerHostedService' does not contain a definition for
|
||||||
|
'StartRevalidationTimer'` and `error CS1729: … does not contain a constructor that takes 13 arguments`
|
||||||
|
(the two new ctor parameters).
|
||||||
|
- **DEVIATION (test seam, and the reason for it):** the plan says "extend the hosted-service tests". **There
|
||||||
|
are none — `grep -rln OtOpcUaServerHostedService tests/` matched nothing**, because `StartAsync` boots
|
||||||
|
the real OPC UA SDK (PKI store, listening socket). A new suite,
|
||||||
|
`OtOpcUaServerHostedServiceRevalidationTests`, drives the timer's lifecycle through an **`internal`**
|
||||||
|
`StartRevalidationTimer()` (new `InternalsVisibleTo` on the Host csproj, the convention six driver
|
||||||
|
projects already use). Everything the timer's lifecycle touches — the options, the clock, the disposal —
|
||||||
|
is exercised exactly as production reaches it; only the SDK boot is skipped.
|
||||||
|
- **DEVIATION (assertion not made):** the plan asks the test to assert disposal happens **before**
|
||||||
|
`SetDataPlaneAuthorizer(null)`. That is **not observable**: both detaches go through `_server?.…`, and
|
||||||
|
`OtOpcUaSdkServer.NodeManager` is created only by the SDK's own `CreateMasterNodeManager` during a real
|
||||||
|
boot, so with no server both calls are no-ops. The ordering is instead guaranteed structurally — the
|
||||||
|
dispose is the **first statement** of `StopAsync`, with a comment saying why — and what IS asserted is
|
||||||
|
that `StopAsync` (and `DisposeAsync`) dispose the timer, with a non-vacuity check that it was live
|
||||||
|
beforehand. **Reviewer: this is the one plan assertion downgraded rather than met.**
|
||||||
|
- The node manager is resolved **per tick** (`() => _server?.NodeManager?.Revalidate…`) rather than
|
||||||
|
captured, so a restart cannot leave the tick walking a dead manager's item map.
|
||||||
|
- Host.Tests: 132 green.
|
||||||
|
|
||||||
|
### T9 — over-the-wire expiry test (key-carrying session)
|
||||||
|
|
||||||
|
- **RED observed (build):** `error CS1503: Argument 2: cannot convert from 'string' to 'byte[]'` — the
|
||||||
|
1.5.378 `UserNameIdentityToken.DecryptedPassword` is raw bytes, not a string (the host already decodes
|
||||||
|
UTF-8; the token had to be built the same way).
|
||||||
|
- **FALSIFICATION (the plan's step 2, run and recorded):** deleting the single `rig.TickRevalidation()`
|
||||||
|
call from `An_expired_key_stops_a_live_subscription_with_no_deploy` makes it **`[FAIL]`** — the expired
|
||||||
|
key goes on streaming. So the denial is produced by the T8 tick and by nothing else. Restored, both
|
||||||
|
cases green.
|
||||||
|
- **DEVIATION (fake choice):** the plan calls for "an `ApiKeyOpcUaUserAuthenticator` over a fake
|
||||||
|
`IApiKeyStore`". The rig instead uses a 6-line `KeyPresentingAuthenticator` returning
|
||||||
|
`OpcUaUserAuthResult.AllowKey(principal, [], keyId)`. The credential-verification path (parse, peppered
|
||||||
|
HMAC, store lookup, deny shapes) is already covered in the Host suite and changes nothing about what
|
||||||
|
this file asks — **whether the SDK hands a monitored item back an identity that still carries the key
|
||||||
|
id**. Grants come from the deployed snapshot, so the authenticator returns no roles.
|
||||||
|
- The tick is driven through the **real `AclRevalidationTimer`**, which required a new `ProjectReference`
|
||||||
|
from the integration test project to `ZB.MOM.WW.OtOpcUa.Host` (the timer lives there). Without it the
|
||||||
|
test would have called the node manager's pass directly and could not have falsified T8.
|
||||||
|
- OpcUaServer.IntegrationTests: 20 green (was 18).
|
||||||
|
|
||||||
|
### T10 — docs
|
||||||
|
|
||||||
|
- `docs/security.md` store table: the "Revocation takes effect" column is now **two** columns — new
|
||||||
|
activation vs already-open session — with an explicit ⚠️ paragraph saying the old single column was
|
||||||
|
wrong and why (an open session re-checks the artifact snapshot, never `IApiKeyStore`), and that expiry
|
||||||
|
is the exception on both node kinds.
|
||||||
|
- `docs/security.md` new subsection **"Bounding the key store"** — the lock hazard, both knobs, what a
|
||||||
|
timeout looks like in the log, the counter to watch, why a timeout denies, and why faults are never
|
||||||
|
cached.
|
||||||
|
- `docs/security.md` revocation bullets: split into the **deployment axis** and the **clock axis**; the
|
||||||
|
live-subscription bullet now names **two triggers**, the timer's key and default, the silenced-not-closed
|
||||||
|
semantics with re-activation recovery, and the event-monitored-item residual.
|
||||||
|
- `CLAUDE.md`: the "asymmetric revocation" sentence corrected to activation-scoped (naming the old wording
|
||||||
|
as wrong), a new bullet for the S18 bound, and the stage-6 "Revocation reaches live subscriptions" bullet
|
||||||
|
extended with the second trigger and the pass-local-memo constraint.
|
||||||
|
- **Beyond the plan's file list:** `docs/Configuration.md` gained rows for all three new keys. It carries
|
||||||
|
the field-by-field tables for `Security:ApiKeys` and `Security:Acl`, so shipping the knobs without them
|
||||||
|
would have been the exact doc drift this plan's cross-cutting requirement is about.
|
||||||
|
|
||||||
|
### T12 — residuals filed
|
||||||
|
|
||||||
|
- **R1** → Gitea **#562** — *ACL: event/alarm monitored items are revalidated by neither trigger*
|
||||||
|
(`https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/562`).
|
||||||
|
- **R2** → Gitea **#563** — *ACL: an expired API key keeps its session's roles while
|
||||||
|
`Security:Acl:Enabled` is false* (`https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/563`).
|
||||||
|
- **R3** (activation-time revocation now bounded by `StoreCacheSeconds`) needs no issue — it is documented
|
||||||
|
behaviour after T10, in both `docs/security.md` and `docs/Configuration.md`.
|
||||||
|
|
||||||
|
### T11 — LIVE GATE
|
||||||
|
|
||||||
|
**Left `pending` for the orchestrator**, per the execution brief. Nothing in this branch has been run
|
||||||
|
against the docker-dev rig; the nine-step recipe in Finding 2 → Live gate is unchanged and still applies.
|
||||||
|
Two notes for whoever runs it:
|
||||||
|
|
||||||
|
- `Security:Acl:RevalidationIntervalSeconds` defaults to 60, so step 6's "within one revalidation
|
||||||
|
interval" is ≤ 60 s unless the rig overrides it. Setting it to `15` shortens the wall-clock wait without
|
||||||
|
changing what is proven.
|
||||||
|
- Step 8's cache-hit leg reads `otopcua.opcua.apikey_store.bounded{outcome=cache_hit}`; the instrument only
|
||||||
|
exists on an **admin/fused** node, since the artifact store is not wrapped.
|
||||||
|
|
||||||
|
### Summary of what a reviewer should look at
|
||||||
|
|
||||||
|
1. **`Program.cs` → `DataPlaneAuthRegistration`** (T1): a verbatim move of the authorizer + key-store
|
||||||
|
branches out of top-level statements so they could be guarded. Confirm no branch changed.
|
||||||
|
2. **`OtOpcUaServerHostedServiceRevalidationTests`** (T8): the plan's dispose-**before**-`SetDataPlaneAuthorizer(null)`
|
||||||
|
ordering is guaranteed structurally but **not asserted** — see T8's notes for why it is unobservable.
|
||||||
|
3. **`BoundedApiKeyStore.IsExpired`** (T3): a two-line fail-closed addition beyond the plan, so the TTL
|
||||||
|
cannot resurrect a clock-expired key.
|
||||||
|
4. New public members added purely so wiring could be pinned by reference: `DataPlaneAuthorizer.Clock`,
|
||||||
|
`BoundedApiKeyStore.Inner` / `.Clock`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review outcome — FIX-FIRST, applied 2026-08-20
|
||||||
|
|
||||||
|
Coordinator verdict on the branch was **FIX-FIRST** with four items. All four applied; Host.Tests and
|
||||||
|
OpcUaServer.Tests rerun green.
|
||||||
|
|
||||||
|
1. **[Important] `IsExpired` had no test — the review was right, and the gap was mine.** T3's execution
|
||||||
|
note called the guard a "fail-closed refinement" and shipped it with *no case that fails without it*:
|
||||||
|
deleting `&& !IsExpired(cached, utcNow)` left all 14 `BoundedApiKeyStoreTests` green. That is the
|
||||||
|
unchanged-observable trap — the guard's whole effect is invisible unless a cached entry outlives its
|
||||||
|
key's `ExpiresUtc` **inside** the TTL, which no existing case constructed.
|
||||||
|
Added **`A_cached_key_that_expires_by_the_clock_is_not_served`**: key expiring at `start + 2 s`, TTL
|
||||||
|
60 s, first `FindAsync` non-null (and now cached); flip the inner store to `Revoked`; advance the fake
|
||||||
|
clock 5 s — past `ExpiresUtc`, far inside the TTL; assert `null` and `inner.Calls == 2`. The
|
||||||
|
`Revoked` flip is deliberate: it makes "served from cache" and "re-read" differ in the **answer**, not
|
||||||
|
only in a call count, so a build that re-read and ignored the result cannot pass either.
|
||||||
|
**RED confirmed by falsification:** with the guard removed, exactly **1 failed / 14 passed** — only the
|
||||||
|
new case — then restored, 15/15 green.
|
||||||
|
2. **[Minor] `cache_hit` was metered before the expiry guard could reject.** `TryServe` counted a hit for
|
||||||
|
an entry that then fell through to a store read, so the counter over-reported. The `Add` moved to the
|
||||||
|
two call sites, **after** the guard, behind a `MeterCacheHit()` helper; `TryServe` is now purely a
|
||||||
|
lookup. This matters operationally — `cache_hit` is the ratio §"Bounding the key store" tells an
|
||||||
|
operator to watch.
|
||||||
|
3. **[Minor] Added `A_node_with_neither_role_registers_the_deny_all_authenticator`** — asserts the
|
||||||
|
`IOpcUaUserAuthenticator` singleton **instance** is `NullOpcUaUserAuthenticator.Instance` and that no
|
||||||
|
`IApiKeyStore` is registered. It closes the third branch of the role conditional, which the guard suite
|
||||||
|
had left unpinned even though that branch is the one whose absence stops a node booting.
|
||||||
|
4. **[Minor] `StartRevalidationTimer` now disposes before overwriting.** `StartAsync` can run again when a
|
||||||
|
host restarts the service around the same instance, and the overwritten field would have left the
|
||||||
|
previous timer armed with nothing left to dispose it — the dispose-before-overwrite rule the driver
|
||||||
|
reconnect paths already carry.
|
||||||
|
|
||||||
|
**Verification after the fixes:** solution build 0 errors · Host.Tests **134 passed / 0 failed** (was 132)
|
||||||
|
· OpcUaServer.Tests **493 passed / 1 skipped**. T11 remains `pending` for the orchestrator.
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
{
|
{
|
||||||
"id": "T1",
|
"id": "T1",
|
||||||
"subject": "Register TimeProvider in DI (shared by authorizer, key store and revalidation timer) + ApiKeyOptions.StoreTimeoutMs/StoreCacheSeconds + AclOptions.RevalidationIntervalSeconds (03/S18, 03/S19)",
|
"subject": "Register TimeProvider in DI (shared by authorizer, key store and revalidation timer) + ApiKeyOptions.StoreTimeoutMs/StoreCacheSeconds + AclOptions.RevalidationIntervalSeconds (03/S18, 03/S19)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T2",
|
"id": "T2",
|
||||||
"subject": "BoundedApiKeyStore: hard per-call deadline on the key store under the SessionManager lock, fail-closed (03/S18)",
|
"subject": "BoundedApiKeyStore: hard per-call deadline on the key store under the SessionManager lock, fail-closed (03/S18)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1"
|
"T1"
|
||||||
]
|
]
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T3",
|
"id": "T3",
|
||||||
"subject": "BoundedApiKeyStore: short TTL read-through cache + falsification control (a revoked key is denied once the TTL has passed)",
|
"subject": "BoundedApiKeyStore: short TTL read-through cache + falsification control (a revoked key is denied once the TTL has passed)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T2"
|
"T2"
|
||||||
]
|
]
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T4",
|
"id": "T4",
|
||||||
"subject": "otopcua.opcua.apikey_store.bounded counter (outcome=timeout|cache_hit) + MeterListener assertions",
|
"subject": "otopcua.opcua.apikey_store.bounded counter (outcome=timeout|cache_hit) + MeterListener assertions",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T3"
|
"T3"
|
||||||
]
|
]
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T5",
|
"id": "T5",
|
||||||
"subject": "Wire BoundedApiKeyStore on admin/fused nodes in Program.cs + registration guard test; correct the authenticator's 'no network I/O at all' remark",
|
"subject": "Wire BoundedApiKeyStore on admin/fused nodes in Program.cs + registration guard test; correct the authenticator's 'no network I/O at all' remark",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T4"
|
"T4"
|
||||||
]
|
]
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T6",
|
"id": "T6",
|
||||||
"subject": "AclRevalidationTimer (pure, TimeProvider-driven, non-throwing tick) + unit tests (03/S19)",
|
"subject": "AclRevalidationTimer (pure, TimeProvider-driven, non-throwing tick) + unit tests (03/S19)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1"
|
"T1"
|
||||||
]
|
]
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T7",
|
"id": "T7",
|
||||||
"subject": "Node-manager expiry tests (FakeTimeProvider advanced past ExpiresUtc, no deploy) + correct the ':316-328' only-trigger xmldoc",
|
"subject": "Node-manager expiry tests (FakeTimeProvider advanced past ExpiresUtc, no deploy) + correct the ':316-328' only-trigger xmldoc",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1"
|
"T1"
|
||||||
]
|
]
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T8",
|
"id": "T8",
|
||||||
"subject": "Wire the revalidation timer into OtOpcUaServerHostedService (create after StartAsync, dispose FIRST in StopAsync) + ordering test",
|
"subject": "Wire the revalidation timer into OtOpcUaServerHostedService (create after StartAsync, dispose FIRST in StopAsync) + ordering test",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T6",
|
"T6",
|
||||||
"T7"
|
"T7"
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T9",
|
"id": "T9",
|
||||||
"subject": "Over-the-wire AclKeyExpiryEnforcementTests: key-carrying session, clock advanced past expiry, item goes BadUserAccessDenied with no deploy (+ negative control)",
|
"subject": "Over-the-wire AclKeyExpiryEnforcementTests: key-carrying session, clock advanced past expiry, item goes BadUserAccessDenied with no deploy (+ negative control)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T8"
|
"T8"
|
||||||
]
|
]
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T10",
|
"id": "T10",
|
||||||
"subject": "Docs: docs/security.md store table + revocation bullets, and the CLAUDE.md 'asymmetric revocation' sentence (activation vs already-open session)",
|
"subject": "Docs: docs/security.md store table + revocation bullets, and the CLAUDE.md 'asymmetric revocation' sentence (activation vs already-open session)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T5",
|
"T5",
|
||||||
"T8"
|
"T8"
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T12",
|
"id": "T12",
|
||||||
"subject": "File residuals R1 (event monitored items revalidated by nothing) and R2 (expired key retains roles while Security:Acl:Enabled=false)",
|
"subject": "File residuals R1 (event monitored items revalidated by nothing) and R2 (expired key retains roles while Security:Acl:Enabled=false)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -90,6 +90,21 @@
|
|||||||
# prior dark-switch shape). The ClusterNode.GrpcPort column (seeded by
|
# prior dark-switch shape). The ClusterNode.GrpcPort column (seeded by
|
||||||
# docker-dev/seed/seed-clusters.sql) points central's dial supervisor at each node's :4056.
|
# docker-dev/seed/seed-clusters.sql) points central's dial supervisor at each node's :4056.
|
||||||
#
|
#
|
||||||
|
# Production-faithful auth (R3-01, archreview 03/S14): central-1/central-2's
|
||||||
|
# Security__Auth__DisableLogin defaults to "true" here (byte-identical to the previous hardcoded
|
||||||
|
# value) but is now `${OTOPCUA_DISABLE_LOGIN:-true}` — a one-variable flip to the REAL cookie
|
||||||
|
# login flow instead of an edit-and-remember-to-revert. DisableLogin registers an always-succeeding
|
||||||
|
# handler UNDER the cookie scheme name, so every live gate run with the default masks the AdminUI
|
||||||
|
# cookie FallbackPolicy entirely — which is exactly how 03/S14 (the FallbackPolicy blocking every
|
||||||
|
# mesh gRPC call on a login-enabled admin node) survived seven phases of prior live gates. To drive
|
||||||
|
# this rig production-faithfully:
|
||||||
|
# OTOPCUA_DISABLE_LOGIN=false docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2
|
||||||
|
# Sign in as `alice` / `password` (or `bob`) against the shared GLAuth (10.100.0.35:3893,
|
||||||
|
# dc=zb,dc=local) — the same LDAP users every other node in this file already authenticates OPC UA
|
||||||
|
# sessions against. Restore the default with `docker compose ... up -d central-1 central-2` (no env
|
||||||
|
# var). See docs/security.md "Mesh gRPC ingress is AllowAnonymous by design" for what this actually
|
||||||
|
# exercises.
|
||||||
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# docker compose -f docker-dev/docker-compose.yml up -d --build
|
# docker compose -f docker-dev/docker-compose.yml up -d --build
|
||||||
# open http://localhost:9200 # central Blazor admin UI
|
# open http://localhost:9200 # central Blazor admin UI
|
||||||
@@ -382,7 +397,7 @@ services:
|
|||||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||||
Security__Auth__DisableLogin: "true"
|
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
|
||||||
# Map the shared-GLAuth data-plane groups (ReadOnly/WriteOperate/...) to data-plane role
|
# Map the shared-GLAuth data-plane groups (ReadOnly/WriteOperate/...) to data-plane role
|
||||||
# strings the OPC UA session ACL gates read. The DB LdapGroupRoleMapping is AdminRole-typed
|
# strings the OPC UA session ACL gates read. The DB LdapGroupRoleMapping is AdminRole-typed
|
||||||
# (AdminUI roles); this free-form GroupToRole baseline is the path for data-plane roles.
|
# (AdminUI roles); this free-form GroupToRole baseline is the path for data-plane roles.
|
||||||
@@ -508,7 +523,7 @@ services:
|
|||||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||||
Security__Auth__DisableLogin: "true"
|
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
|
||||||
# Data-plane group→role baseline (see central-1).
|
# Data-plane group→role baseline (see central-1).
|
||||||
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
|
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
|
||||||
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
|
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
|
||||||
|
|||||||
@@ -79,12 +79,15 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `Username` | string | `apikey` | The fixed username a key-bearing client sends in its OPC UA UserName token. Carries no information — the key identifies the principal — but is checked rather than ignored so a typo is an explicit denial. |
|
| `Username` | string | `apikey` | The fixed username a key-bearing client sends in its OPC UA UserName token. Carries no information — the key identifies the principal — but is checked rather than ignored so a typo is an explicit denial. |
|
||||||
| `Pepper` | string | `""` | Server-side pepper for the stored key hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** No default on purpose: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a deployment with none of the protection it believes it has. Rotating it invalidates every existing key. |
|
| `Pepper` | string | `""` | Server-side pepper for the stored key hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** No default on purpose: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a deployment with none of the protection it believes it has. Rotating it invalidates every existing key. |
|
||||||
|
| `StoreTimeoutMs` | int | `2000` | Hard per-call deadline on the API-key store lookup that runs inline on session activation (arch-review 03/S18). Exceeding it **denies** the activation, logs a distinct Warning and meters `otopcua.opcua.apikey_store.bounded{outcome=timeout}`. `≤ 0` disables the deadline — do not, on a node with a ConfigDb: the SDK holds a `SessionManager`-wide lock across that lookup, so an unanswered query stalls **every** session activation on the server. Applies only to `ConfigDbApiKeyStore`; the in-memory artifact store on a driver-only node is not wrapped. |
|
||||||
|
| `StoreCacheSeconds` | int | `5` | TTL of the read-through cache in front of that store, so a burst of activations costs one round trip. `0` disables it (a store read per activation). Bounds how long an `Enabled` revocation authored in the ConfigDb takes to reach a **new** activation; it has no effect on already-open sessions (those re-check the artifact snapshot) and cannot delay an `ExpiresUtc` — a cached hit that has since expired is re-read rather than served. |
|
||||||
|
|
||||||
**`Security:Acl`** key fields (`AclOptions`):
|
**`Security:Acl`** key fields (`AclOptions`):
|
||||||
|
|
||||||
| Key | Type | Default | Meaning |
|
| Key | Type | Default | Meaning |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `Enabled` | bool | `false` | `true` makes scoped `NodeAcl` grants decide **write**, **alarm-command** and **HistoryRead** authorization, *replacing* the server-wide `WriteOperate` / `AlarmAck` role checks. `false` keeps those role checks and leaves HistoryRead ungated — byte-for-byte the pre-stage-5 behaviour. Read, Browse and Subscribe are ungated either way. |
|
| `Enabled` | bool | `false` | `true` makes scoped `NodeAcl` grants decide **write**, **alarm-command** and **HistoryRead** authorization, *replacing* the server-wide `WriteOperate` / `AlarmAck` role checks. `false` keeps those role checks and leaves HistoryRead ungated — byte-for-byte the pre-stage-5 behaviour. Read, Browse and Subscribe are ungated either way. |
|
||||||
|
| `RevalidationIntervalSeconds` | int | `60` | How often live monitored items are re-authorized against the clock, in addition to on every deployment (arch-review 03/S19). A key's `ExpiresUtc` passes with the clock, so without this an expired key kept feeding a live subscription until a deployment happened to land — indefinitely on a fleet deployed once and left running. `0` disables the timer (deploy-triggered only). **Inert while `Enabled` is false**, since the pass early-returns when enforcement is off. An affected session is silenced with `BadUserAccessDenied`, **not closed**, and recovers on re-activation. |
|
||||||
|
|
||||||
> **Flipping `Security:Acl:Enabled` is a config change, not a redeploy** — both paths are wired in both modes (the node builds its permission trie on every deploy regardless), the same dark-switch discipline as [`MeshTransport`](#meshtransport), [`ConfigSource`](#configsource) and [`Telemetry`](#telemetry). It defaults off because the first deployment carrying the code would otherwise start denying every client that has no key, **including ScadaBridge's Data Connection Layer, which consumes this address space from another repo.** Enforcement fails closed on every unresolvable case, so a wiring or config mistake presents as *everything denied* — check the `OpcUaPublish: data-plane authority generation N installed` line for a non-zero grant count before debugging an individual rule. See [`security.md` § Node ACL enforcement](security.md#node-acl-enforcement-securityaclenabled).
|
> **Flipping `Security:Acl:Enabled` is a config change, not a redeploy** — both paths are wired in both modes (the node builds its permission trie on every deploy regardless), the same dark-switch discipline as [`MeshTransport`](#meshtransport), [`ConfigSource`](#configsource) and [`Telemetry`](#telemetry). It defaults off because the first deployment carrying the code would otherwise start denying every client that has no key, **including ScadaBridge's Data Connection Layer, which consumes this address space from another repo.** Enforcement fails closed on every unresolvable case, so a wiring or config mistake presents as *everything denied* — check the `OpcUaPublish: data-plane authority generation N installed` line for a non-zero grant count before debugging an individual rule. See [`security.md` § Node ACL enforcement](security.md#node-acl-enforcement-securityaclenabled).
|
||||||
|
|
||||||
@@ -190,6 +193,8 @@ On the docker-dev rig every node carries these keys already; the rig's compose d
|
|||||||
|
|
||||||
See the [ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-to-grpc-design.md), [implementation plan](plans/2026-08-06-clusterclient-to-grpc.md), [tracking doc](plans/2026-08-06-clusterclient-to-grpc-tracking.md) and [live gate](plans/2026-08-06-clusterclient-to-grpc-live-gate.md) for the migration's phase-by-phase history — those are historical records of the cutover and are not updated further by this sweep.
|
See the [ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-to-grpc-design.md), [implementation plan](plans/2026-08-06-clusterclient-to-grpc.md), [tracking doc](plans/2026-08-06-clusterclient-to-grpc-tracking.md) and [live gate](plans/2026-08-06-clusterclient-to-grpc-live-gate.md) for the migration's phase-by-phase history — those are historical records of the cutover and are not updated further by this sweep.
|
||||||
|
|
||||||
|
**On an admin-role node, `NodeCommandService` and `CentralCommandService` are `AllowAnonymous` by design** — the AdminUI cookie `FallbackPolicy` would otherwise 401/redirect a correctly-PSK'd call before the interceptor above ever ran (archreview 03/S14). See [`security.md` § Mesh gRPC ingress is `AllowAnonymous` by design](security.md#mesh-grpc-ingress-is-allowanonymous-by-design).
|
||||||
|
|
||||||
### `ConfigSource` / `ConfigServe` (config fetch-and-cache)
|
### `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.
|
- **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.
|
||||||
@@ -217,6 +222,8 @@ See the [ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-t
|
|||||||
|
|
||||||
**`RevisionHash == SHA-256(artifact bytes)` is load-bearing.** `ConfigComposer` computes the deployment's revision hash as the lowercase-hex SHA-256 of the exact artifact bytes, so the node verifies a fetch against the `RevisionHash` the dispatch already carries — no hash rides in the proto. If a future change makes the revision hash anything else, every fetch fails verification.
|
**`RevisionHash == SHA-256(artifact bytes)` is load-bearing.** `ConfigComposer` computes the deployment's revision hash as the lowercase-hex SHA-256 of the exact artifact bytes, so the node verifies a fetch against the `RevisionHash` the dispatch already carries — no hash rides in the proto. If a future change makes the revision hash anything else, every fetch fails verification.
|
||||||
|
|
||||||
|
**`DeploymentArtifactService` is `AllowAnonymous` by design** — same reasoning as `NodeCommandService`/`CentralCommandService` above: the AdminUI cookie `FallbackPolicy` on an admin-role node would otherwise block a correctly-keyed fetch before `ConfigServeAuthInterceptor`'s verdict matters. See [`security.md` § Mesh gRPC ingress is `AllowAnonymous` by design](security.md#mesh-grpc-ingress-is-allowanonymous-by-design).
|
||||||
|
|
||||||
#### Artifact compression (2026-08-15)
|
#### Artifact compression (2026-08-15)
|
||||||
|
|
||||||
The artifact blob is **gzip-compressed, inside the hashed bytes**. `ConfigComposer.Pack` gzips the
|
The artifact blob is **gzip-compressed, inside the hashed bytes**. `ConfigComposer.Pack` gzips the
|
||||||
|
|||||||
+109
-1
@@ -164,6 +164,93 @@ mostly reusable — what was always missing is the trigger.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## What a (re)spawn re-provisions
|
||||||
|
|
||||||
|
A driver child can come into existence by **three** routes, and until 2026-08-20 only the first of them
|
||||||
|
handed the child everything it needs to be a working driver. That is archreview **03/S16** (and its worse
|
||||||
|
half, **03/S6**): a child that exists without its desired subscription set is a *dead* driver — it
|
||||||
|
connects, reports `Connected`, publishes nothing and raises no alarms, and the #488 periodic reconcile
|
||||||
|
cannot rescue it (it early-returns on an empty desired set). The fix makes the desired set **part of what
|
||||||
|
it means to have a child** rather than a later step in the apply pipeline.
|
||||||
|
|
||||||
|
**`DriverHostActor._desiredByDriver` is the single authority.** It is a per-driver
|
||||||
|
`(ValueRefs, AlarmRefs)` pair, cleared and repopulated **whole** on every apply in the same pass that
|
||||||
|
rebuilds the routing maps — keyed over the union of the value-ref map, the alarm-ref map and the running
|
||||||
|
`_children`, so a driver with no authored tags gets an explicit **empty** set rather than being absent.
|
||||||
|
Everything that hands a child a `SetDesiredSubscriptions` goes through the one helper
|
||||||
|
`SendDesiredSubscriptions(actor, driverId)`. (An empty set is meaningful, not a no-op to skip: the child
|
||||||
|
routes it to `Unsubscribe`, dropping a stale handle.)
|
||||||
|
|
||||||
|
| Route | Trigger | What re-provisions it |
|
||||||
|
|---|---|---|
|
||||||
|
| **Deploy reconcile** | `ApplyDriverSpecs` → `SpawnChild` | `SpawnChild` sends `InitializeRequested` then the desired set; the apply's bulk push then sends the (freshly rebuilt) set to every child. |
|
||||||
|
| **Operator Restart** | `/hosts` → Restart → `HandleRestartDriver` | Stops the child, respawns from the cached spec — and `SpawnChild` sends the desired set, because it always does. **This is the 03/S16 fix**: there is no bulk push on this path, and before it the respawned child subscribed to nothing until the next deployment. |
|
||||||
|
| **Supervision restart** | a child throws ⇒ Akka's **default decider** ⇒ `Restart` | `DriverInstanceActor.PostRestart` Tells the host `ReprovisionRequested`; `HandleReprovisionRequested` logs a Warning naming the crash type, re-sends `InitializeRequested` (non-stub children) and the desired set. |
|
||||||
|
| **Force Reconnect** | `/hosts` → Force Reconnect → `HandleReconnectDriver` | **Nothing — verified.** It Tells `ForceReconnect` to the *same* child actor, so the instance and its `_desiredRefs`/`_desiredAlarmRefs` survive and the reconnect's Connected entry re-applies them itself. Pinned by `Force_reconnect_keeps_the_desired_set_and_re_subscribes` so a future edit to the restart paths cannot break the one that was already correct. |
|
||||||
|
|
||||||
|
Things to know before touching any of this:
|
||||||
|
|
||||||
|
- ⚠️ **The supervision route is the invisible one, and it was the worst.** There is **no**
|
||||||
|
`SupervisorStrategy` anywhere under `src/Server`, so a child that throws gets Akka's default `Restart`.
|
||||||
|
A restart recreates the actor object around the **same** `IDriver` — the Props closure captured it —
|
||||||
|
and its `PreRestart` runs `PostStop`, which calls `IDriver.ShutdownAsync`. It does **not** run
|
||||||
|
`SpawnChild`, and the child's `IActorRef` is unchanged, so the host had no notification a new
|
||||||
|
incarnation existed at all. Net effect before the hook: the child sat in `Connecting` forever around a
|
||||||
|
shut-down driver, with its desired set gone, and nothing logged the non-recovery.
|
||||||
|
- **Replacing the default decider is deliberately out of scope.** A `Stop` directive would silently
|
||||||
|
remove a driver; a bounded retry count would silently stop one after N crashes. Both change failure
|
||||||
|
semantics for every driver in the fleet and deserve their own plan and live gate. The reprovision hook
|
||||||
|
makes the *current* infinite-restart behaviour correct instead of dead.
|
||||||
|
- ⚠️ **…which is exactly why the reprovision is RATE-LIMITED.** The default decider restarts a faulting
|
||||||
|
child **immediately and without limit**, and the hook now makes every one of those restarts do
|
||||||
|
`ShutdownAsync` + `InitializeAsync` + `SubscribeAsync` — so a driver that faults on every value would
|
||||||
|
hammer its device as fast as it can crash. `DriverHostActor` keeps a per-driver last-reprovision stamp
|
||||||
|
and holds anything inside `DefaultReprovisionBackoff` (10 s, matched to
|
||||||
|
`DriverInstanceActor.DefaultReconnectInterval` because it drives the same connect work). The held
|
||||||
|
request is **deferred, not dropped** — one `StartSingleTimer` per driver, so a burst collapses to a
|
||||||
|
single pending reprovision — because dropping it would leave a child whose *final* crash fell inside
|
||||||
|
the window un-provisioned forever, which is the 03/S16 failure this hook exists to close. Each
|
||||||
|
deferral logs a Warning naming the crash type, so a crash loop is visible rather than merely quiet.
|
||||||
|
- **A reprovision is routed by INCARNATION, not by driver id.** `ReprovisionRequested` is delivered
|
||||||
|
asynchronously, so one can still be in flight from an old incarnation after `HandleRestartDriver`
|
||||||
|
stopped that child and spawned a replacement under the same id. Delivered to the new child — which is
|
||||||
|
in `Connecting`, where `InitializeRequested` *is* handled — it would start a second, concurrent
|
||||||
|
`InitializeAsync`, racing the dispose-before-overwrite that #527's teardown exists to serialise
|
||||||
|
(`ModbusDriver._transport` / `MqttDriver._connection` each leak a transport on that race). The handler
|
||||||
|
therefore checks `Sender` against the child it currently holds and drops anything else; the deferred
|
||||||
|
path carries the original ref so the same check still applies when the timer re-delivers it.
|
||||||
|
- **Ordering on a respawning deploy, and its one accepted cost.** `ApplyDriverSpecs` (which spawns) runs
|
||||||
|
before the pass that rebuilds `_desiredByDriver`, so a child respawned by a deploy — raw tags are folded
|
||||||
|
into the merged `DriverConfig`, so a tag-set edit changes the spec and trips the #516 config respawn —
|
||||||
|
is first handed the **previous** apply's set, then the new one by the bulk push. Akka's per-pair
|
||||||
|
ordering guarantees it **converges**; it does not guarantee the transient is inert, because the child
|
||||||
|
runs concurrently with the rest of the apply handler and a fast-initialising driver can reach
|
||||||
|
`Connected` and subscribe the stale set first. The cost is one extra subscribe round-trip
|
||||||
|
(`HandleSubscribeAsync` drops the prior subscription before establishing the new one); a real device
|
||||||
|
driver, whose initialise opens a socket, rarely wins that race. Withholding the spawn-time send to avoid
|
||||||
|
it would simply restore 03/S16, since the Restart and supervision routes have no bulk push coming.
|
||||||
|
- ⚠️ **Convergence is not free on the ALARM side, and "a subscription already exists" is the wrong guard
|
||||||
|
there.** The value path converges by construction — `StoreDesiredSubscriptions` is a full replace and
|
||||||
|
`HandleSubscribeAsync` drops the prior subscription before establishing the new one — but
|
||||||
|
`SubscribeDesiredAlarms` / `HandleSubscribeAlarmsAsync` used to gate purely on
|
||||||
|
`_alarmSubscriptionHandle is not null`, so once a handle existed the alarm set **latched for the life of
|
||||||
|
the connection**. Combined with the transient above, a deploy widening a driver's alarm refs `{X}` →
|
||||||
|
`{X, Y}` could leave a fast-initialising driver subscribed to `{X}` forever, and the drivers that filter
|
||||||
|
their feed on the subscribed `sourceNodeIds` (OPC UA Client, AB CIP, FOCAS) would simply never deliver
|
||||||
|
the new alarm — silently. The guard is now "the desired refs differ from the ones the live handle was
|
||||||
|
established with" (`_subscribedAlarmRefs`), and a changed set **releases the old subscription before
|
||||||
|
re-subscribing** so a filtering driver does not accumulate two overlapping ones. There is still
|
||||||
|
deliberately no unsubscribe-on-**empty**: a removed alarm tag's condition node is torn down on the
|
||||||
|
address-space rebuild and `ForwardNativeAlarm` drops any transition whose `ConditionId` no longer maps.
|
||||||
|
- **Restart tests must assert a FUNCTION, never existence.** `RestartDriver_respawns_the_child_without_an_actor_name_collision`
|
||||||
|
asserts only that a child exists after a restart, and it passed green against the dead driver 03/S16
|
||||||
|
describes. It is kept — its own subject, the `InvalidActorNameException`, is real — but the load-bearing
|
||||||
|
suite is `DriverHostActorRestartTests`, which asserts that the new incarnation *subscribed* and
|
||||||
|
*re-initialised*. Its file remarks also record the supervision crash seam and why the obvious
|
||||||
|
alternatives (a throwing health publisher, a throwing `SubscribeAsync`, `Kill.Instance`) do not work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## IDriverHealthPublisher — health pub/sub sink
|
## IDriverHealthPublisher — health pub/sub sink
|
||||||
|
|
||||||
[`Core.Abstractions/IDriverHealthPublisher.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs)
|
[`Core.Abstractions/IDriverHealthPublisher.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs)
|
||||||
@@ -267,7 +354,8 @@ The `Connected`-state health tick now also evaluates a watchdog, and self-sends
|
|||||||
|
|
||||||
1. the driver reports a state other than `Healthy`, **and**
|
1. the driver reports a state other than `Healthy`, **and**
|
||||||
2. `DriverHealth.LastSuccessfulRead` has not advanced for `DefaultCommsLossTimeout` (90 s = three ticks),
|
2. `DriverHealth.LastSuccessfulRead` has not advanced for `DefaultCommsLossTimeout` (90 s = three ticks),
|
||||||
measured from the Connected entry when the driver has never reported a successful read.
|
measured from **the later of that read stamp and the current Connected entry** (see "the grace is per
|
||||||
|
connection" below).
|
||||||
|
|
||||||
**The conjunction is the whole design, and "non-Healthy for N polls" is the wrong rule.** `Degraded` means
|
**The conjunction is the whole design, and "non-Healthy for N polls" is the wrong rule.** `Degraded` means
|
||||||
*connected but some data is bad* — the enum says so. Several drivers publish health **per tag inside their
|
*connected but some data is bad* — the enum says so. Several drivers publish health **per tag inside their
|
||||||
@@ -281,6 +369,26 @@ still needed as the second conjunct, because a `Healthy` driver with nothing sub
|
|||||||
reads and must not be declared dead for being idle. Both halves are pinned by their own test in
|
reads and must not be declared dead for being idle. Both halves are pinned by their own test in
|
||||||
`DriverInstanceActorCommsLossWatchdogTests`, each of which goes red when the *other* conjunct is deleted.
|
`DriverInstanceActorCommsLossWatchdogTests`, each of which goes red when the *other* conjunct is deleted.
|
||||||
|
|
||||||
|
**The grace is per CONNECTION — the baseline is `max(LastSuccessfulRead, connectedSince)`, not
|
||||||
|
`LastSuccessfulRead ?? connectedSince`** (archreview 03/S17, fixed 2026-08-20). The two spellings differ
|
||||||
|
for exactly the case that matters, and the `??` one collapsed the grace to a single health tick. Because
|
||||||
|
`LastSuccessfulRead` is *preserved* across `Degraded`/`Faulted` writes — that preservation is the design
|
||||||
|
described above — a device that accepts a TCP connection but never serves data (a half-broken PLC, a wrong
|
||||||
|
unit id, a firewalled read path) reconnects carrying a read stamp frozen from before the outage, already
|
||||||
|
far older than the threshold. Measuring from that stamp alone re-trips on the **first** tick of every new
|
||||||
|
connection, so the reconnect cycle ran at health-tick cadence (~30 s in production) rather than at the
|
||||||
|
designed 90 s. Taking the *later* of the two gives each connection its own full window, which is what
|
||||||
|
re-stamping `connectedSince` on every Connected entry was always for; `??` only ever helped a driver that
|
||||||
|
had never read at all, and the max still covers that case.
|
||||||
|
|
||||||
|
What this does **not** change: the conjunction, the disable-at-zero hatch, and a driver that stops reading
|
||||||
|
*within* a stable connection (its read stamp is the later term, so it still trips on schedule measured
|
||||||
|
from that read). And it **paces** the half-broken-device cycle rather than ending it — expect at most one
|
||||||
|
reconnect attempt per threshold per connection, not silence. Pinned by
|
||||||
|
`A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again` (the delay) and
|
||||||
|
`A_half_broken_device_is_still_declared_lost_once_the_grace_elapses` (the anti-vacuity half — without it,
|
||||||
|
a "fix" that simply disabled the watchdog after the first connection would look correct).
|
||||||
|
|
||||||
Two consequences worth knowing:
|
Two consequences worth knowing:
|
||||||
|
|
||||||
- **`CalculationDriver` can never trip it, correctly.** It reports `Healthy` whenever it is running and has
|
- **`CalculationDriver` can never trip it, correctly.** It reports `Healthy` whenever it is running and has
|
||||||
|
|||||||
@@ -190,6 +190,14 @@ transports. ScadaBridge itself has since closed that gap with this identical
|
|||||||
interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a
|
interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a
|
||||||
later hardening pass. See the design doc's superseded note for the full history.
|
later hardening pass. See the design doc's superseded note for the full history.
|
||||||
|
|
||||||
|
**On a fused `admin,driver` node, `TelemetryStreamService` and `NodeCommandService` are `AllowAnonymous`
|
||||||
|
by design** — a *different* gate from the interceptor above. `TelemetryStreamAuthInterceptor` is the PSK
|
||||||
|
check; on an admin-role node the AdminUI cookie `FallbackPolicy` sits in front of it and would 401/redirect
|
||||||
|
a correctly-keyed call before the interceptor's verdict matters, because it runs in the authorization
|
||||||
|
middleware, strictly *before* the interceptor executes (archreview 03/S14). A driver-only node never
|
||||||
|
registers that middleware at all, so it is structurally unaffected either way. See
|
||||||
|
[`security.md` § Mesh gRPC ingress is `AllowAnonymous` by design](security.md#mesh-grpc-ingress-is-allowanonymous-by-design).
|
||||||
|
|
||||||
## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493)
|
## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493)
|
||||||
|
|
||||||
`GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that
|
`GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that
|
||||||
|
|||||||
+151
-16
@@ -177,16 +177,59 @@ When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpc
|
|||||||
the Host wires the real `ApiKeyOpcUaUserAuthenticator` as a singleton in `Program.cs`. Which
|
the Host wires the real `ApiKeyOpcUaUserAuthenticator` as a singleton in `Program.cs`. Which
|
||||||
**`IApiKeyStore`** backs it depends on the node's role:
|
**`IApiKeyStore`** backs it depends on the node's role:
|
||||||
|
|
||||||
| Node | Store | Revocation takes effect |
|
| Node | Store | An `Enabled = 0` revocation reaches a **new activation** | …and an **already-open session** |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `admin` (including fused `admin,driver`) | `ConfigDbApiKeyStore` — reads the config database per request | On the **next request** |
|
| `admin` (including fused `admin,driver`) | `ConfigDbApiKeyStore` — reads the config database per activation, wrapped in `BoundedApiKeyStore` | On the **next activation**, bounded by `Security:ApiKeys:StoreCacheSeconds` (default 5 s) | On the **next deployment** |
|
||||||
| `driver`-only | `ArtifactApiKeyStore` — reads the keys carried by the deployment artifact, held in memory | On the **next deployment** |
|
| `driver`-only | `ArtifactApiKeyStore` — reads the keys carried by the deployment artifact, held in memory | On the **next deployment** | On the **next deployment** |
|
||||||
| neither role | `NullOpcUaUserAuthenticator` — denies every UserName token | n/a |
|
| neither role | `NullOpcUaUserAuthenticator` — denies every UserName token | n/a | n/a |
|
||||||
|
|
||||||
|
⚠️ **The last column is the correction, and it used to be missing.** This table once said a ConfigDb node
|
||||||
|
revokes "on the next request", which is true only of a **new session activation**. An operation on a
|
||||||
|
session that is *already open* does not re-read `IApiKeyStore` at all — `DataPlaneAuthorizer.Evaluate`
|
||||||
|
re-checks the key against the in-memory **artifact snapshot** (`DataPlaneAuthority.FindUsableKey`), which
|
||||||
|
`OpcUaPublishActor` installs on every apply. So clearing `Enabled` in the ConfigDb stops the *next*
|
||||||
|
connect on an admin node and nothing else until a deployment lands, on **both** node kinds.
|
||||||
|
|
||||||
|
**Expiry is the exception, and it is immediate on both.** `FindUsableKey` compares `ExpiresUtc` against
|
||||||
|
the authorizer's clock on every call, so an `ExpiresUtc` that has already passed denies at once on an
|
||||||
|
open session — and since arch-review 03/S19 it reaches a live **subscription** too, on a timer (see
|
||||||
|
[Data-Plane Authorization](#data-plane-authorization)).
|
||||||
|
|
||||||
The deny-all is registered **explicitly**, not left absent: `OtOpcUaServerHostedService` takes
|
The deny-all is registered **explicitly**, not left absent: `OtOpcUaServerHostedService` takes
|
||||||
`IOpcUaUserAuthenticator` as a required constructor parameter, so an unregistered seam fails DI and the
|
`IOpcUaUserAuthenticator` as a required constructor parameter, so an unregistered seam fails DI and the
|
||||||
node does not boot at all.
|
node does not boot at all.
|
||||||
|
|
||||||
|
### Bounding the key store (`StoreTimeoutMs` / `StoreCacheSeconds`)
|
||||||
|
|
||||||
|
The SDK raises impersonation **synchronously, holding a `SessionManager`-wide event lock**, so the key
|
||||||
|
lookup does not stall one activation — it serializes **every** session activation on the server,
|
||||||
|
Anonymous and X.509 included. On a driver-only node that is harmless (the artifact store is two
|
||||||
|
dictionary probes). On an admin or fused node it is two EF round trips to central SQL, so a ConfigDb that
|
||||||
|
is *slow or hung* rather than *refusing* could hold that lock indefinitely. `BoundedApiKeyStore`
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/BoundedApiKeyStore.cs`) wraps the ConfigDb store — and only it —
|
||||||
|
with two `Security:ApiKeys` knobs:
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `StoreTimeoutMs` | `2000` | Hard per-call deadline on the store. Exceeding it **denies** the activation. `≤ 0` disables the deadline (the pre-R3-05 unbounded behaviour — do not do this on an admin node). |
|
||||||
|
| `StoreCacheSeconds` | `5` | TTL of a read-through cache in front of the store, so a burst of activations costs one round trip. `0` disables it (a read per activation). |
|
||||||
|
|
||||||
|
- **A timeout denies; it never admits.** The deadline surfaces as an `OperationCanceledException` into
|
||||||
|
`ApiKeyOpcUaUserAuthenticator`'s existing catch-all, which returns the same opaque denial as a bad
|
||||||
|
credential. There is no second denial path to get wrong, and the decorator can only ever return what
|
||||||
|
the inner store returned or throw.
|
||||||
|
- **What a timeout looks like:** a distinct Warning — *"ApiKeyStore: the key store did not answer within
|
||||||
|
N ms — denying this activation. Every OPC UA session activation on this server is serialized behind
|
||||||
|
this call; check ConfigDb health"* — plus
|
||||||
|
`otopcua.opcua.apikey_store.bounded{outcome=timeout}`. It is deliberately **not** the ordinary
|
||||||
|
*"no enabled, unexpired key with id X"* line, which would send an operator hunting for a key problem
|
||||||
|
that does not exist.
|
||||||
|
- **What to watch:** `otopcua.opcua.apikey_store.bounded{outcome=cache_hit}`. A busy fused central with no
|
||||||
|
`cache_hit` stream means `StoreCacheSeconds` is 0.
|
||||||
|
- **Faults and timeouts are never cached** — caching a deny would extend an outage past the outage. Only
|
||||||
|
a completed lookup (a hit or an authoritative miss) is stored, and a cached hit whose `ExpiresUtc` has
|
||||||
|
since passed is re-read rather than served, so the TTL can never resurrect an expired key.
|
||||||
|
|
||||||
### LDAP bind flow (`OtOpcUaLdapAuthService`)
|
### LDAP bind flow (`OtOpcUaLdapAuthService`)
|
||||||
|
|
||||||
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)).
|
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)).
|
||||||
@@ -411,17 +454,36 @@ Behaviours worth knowing before enabling it:
|
|||||||
- **A deploy takes effect on sessions that are already open.** The gate stamps each decision with the
|
- **A deploy takes effect on sessions that are already open.** The gate stamps each decision with the
|
||||||
current authority generation rather than the one the session activated under, so an ACL edit lands
|
current authority generation rather than the one the session activated under, so an ACL edit lands
|
||||||
without bouncing clients — in both directions, widening and narrowing.
|
without bouncing clients — in both directions, widening and narrowing.
|
||||||
- **Revocation also reaches an open session.** Every decision re-checks the authenticating key against
|
- **Revocation also reaches an open session — but read the two axes separately.** Every decision
|
||||||
the current snapshot, so a key disabled or expired by a later deployment stops working immediately
|
re-checks the authenticating key against the current snapshot, so an open session is re-authorized on
|
||||||
rather than at the client's next reconnect. On a driver-only node the artifact is the only revocation
|
every operation. What that check can *see* differs by axis:
|
||||||
channel there is, so without this a revoked credential could keep its authority indefinitely.
|
- **The deployment axis** (a key's `Enabled` cleared, a grant edited or removed). The snapshot only
|
||||||
- **Revocation reaches a live subscription too** (#520 stage 6). Every other operation re-asks on each
|
changes when an artifact is applied, so this lands **on the next deployment**, on both node kinds —
|
||||||
request; a subscription is authorized once and then streams, so it needed a push. A deployment
|
including on a fused admin node, whose ConfigDb store is consulted at *activation* only. On a
|
||||||
re-authorizes every live monitored item: one whose grant has gone is queued a `BadUserAccessDenied`
|
driver-only node the artifact is the only revocation channel there is.
|
||||||
and then detached from its node's value fan-out, and a later deployment that restores the grant
|
- **The clock axis** (a key's `ExpiresUtc` passing). `FindUsableKey` compares it against the
|
||||||
re-attaches it. This matters more than it sounds — subscriptions are how machine consumers actually
|
authorizer's clock on every call, so this lands **immediately**, with no deployment.
|
||||||
read this server, so without it revoking a grant would close the operations nobody uses and leave the
|
- **Revocation reaches a live subscription too** (#520 stage 6, extended by arch-review 03/S19). Every
|
||||||
feed running.
|
other operation re-asks on each request; a subscription is authorized once and then streams, so it
|
||||||
|
needed a push. A denied item is queued a `BadUserAccessDenied` and then detached from its node's value
|
||||||
|
fan-out; restoring its authority re-attaches it. There are **two triggers**:
|
||||||
|
- a **deployment**, which re-authorizes every live monitored item (the deployment axis);
|
||||||
|
- the host's **low-frequency timer**, `Security:Acl:RevalidationIntervalSeconds` (default 60 s, `0`
|
||||||
|
disables it, inert while `Security:Acl:Enabled` is false). Without it a key that expired by the
|
||||||
|
clock kept streaming until a deployment happened to land — **indefinitely** on a fleet that has been
|
||||||
|
deployed once and left running, which is exactly the shape a production fleet settles into.
|
||||||
|
|
||||||
|
⚠️ **An expired-key session is silenced, not disconnected.** Its items go `BadUserAccessDenied` and the
|
||||||
|
session stays open, deliberately: a clock-driven denial must look to a client exactly like a
|
||||||
|
deploy-driven one, OPC UA has no honest "expire this session" verb (deleting it server-side gives the
|
||||||
|
client a transport failure rather than an authorization status), and every other operation on that
|
||||||
|
session already denies. **A client that re-activates with a renewed key recovers on the next tick with
|
||||||
|
no reconnect** — closing the session instead would force a full reconnect-and-resubscribe on every
|
||||||
|
machine consumer in the fleet at once when a shared key expired.
|
||||||
|
|
||||||
|
⚠️ **Event/alarm monitored items are revalidated by neither trigger.** The live-item map is fed only
|
||||||
|
from data-change items, so an alarm subscription is gated at creation and never again. Pre-existing —
|
||||||
|
the deployment trigger has the same hole — and tracked as a residual of arch-review 03/S19.
|
||||||
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
|
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
|
||||||
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
|
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
|
||||||
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible
|
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible
|
||||||
@@ -846,6 +908,79 @@ Responses: `202 Accepted` (`{ outcome, deploymentId, revisionHash }`) when a dep
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Mesh gRPC ingress is `AllowAnonymous` by design
|
||||||
|
|
||||||
|
Archreview 03/S14 (Critical, closed R3-01). On an admin-role node `AddOtOpcUaAuth` installs a global
|
||||||
|
`FallbackPolicy = RequireAuthenticatedUser` bound to the cookie scheme (the same fallback that protects
|
||||||
|
every AdminUI page above). ASP.NET Core's authorization middleware applies that fallback to **every
|
||||||
|
endpoint that carries no authorization metadata of its own** — gRPC endpoints included. The five mesh
|
||||||
|
gRPC endpoints (config-serve artifact fetch, central command ack, telemetry stream, node command, LocalDb
|
||||||
|
sync) authenticate with a per-cluster/per-node **PSK bearer inside a gRPC interceptor**, and that
|
||||||
|
interceptor runs *inside* the endpoint invocation — i.e. strictly *after* the authorization middleware.
|
||||||
|
Without an explicit `.AllowAnonymous()` opt-out, a correctly-keyed node call is challenged by the cookie
|
||||||
|
handler before the interceptor's verdict ever matters: on a login-enabled admin node this is a fleet-wide
|
||||||
|
deploy-plane outage (artifact fetches fail, deploy acks are dropped, deployments seal `TimedOut`), and it
|
||||||
|
went unnoticed for seven phases of prior live gates because the docker-dev rig always ran
|
||||||
|
`Security__Auth__DisableLogin=true` (an always-succeeding handler registered *under the cookie scheme
|
||||||
|
name*, so the fallback trivially passes) and every prior mesh-gRPC test built its own auth-free pipeline
|
||||||
|
by hand.
|
||||||
|
|
||||||
|
**Why `AllowAnonymous` is the correct fix, not a hole.** All five interceptors are fail-closed: an unset
|
||||||
|
key rejects every call, and the presented token is compared with `CryptographicOperations.FixedTimeEquals`.
|
||||||
|
`Program.cs:574` states the design contract directly — "the interceptor is the ONLY inbound auth on any
|
||||||
|
endpoint (the libraries verify nothing)" — which was already true of the gRPC pipeline; the missing opt-out
|
||||||
|
made it false of the HTTP pipeline in front of it. The pattern itself predates this fix: static assets
|
||||||
|
(`MapStaticAssets().AllowAnonymous()`), the metrics endpoint (`MapZbMetrics().AllowAnonymous()`), and the
|
||||||
|
headless deploy API above are all `AllowAnonymous` for the identical reason — each is gated by something
|
||||||
|
other than the cookie, and the fallback would otherwise 401/redirect it regardless.
|
||||||
|
|
||||||
|
| Endpoint | Role gate | Affected without `AllowAnonymous`, on a login-enabled node |
|
||||||
|
|---|---|---|
|
||||||
|
| `DeploymentArtifactService.Fetch` | `hasAdmin && ConfigServe:GrpcListenPort > 0` | Always — every `FetchAndCache` node's artifact fetch. |
|
||||||
|
| `CentralCommandService.ReportApply` | same | Always — every node's deploy ack, from every cluster. |
|
||||||
|
| `TelemetryStreamService.Subscribe` | `hasDriver && Telemetry:GrpcListenPort > 0` | Only on a **fused** `admin,driver` node (e.g. docker-dev's central pair) — a driver-only node never registers the cookie stack at all, so it is structurally unaffected either way. |
|
||||||
|
| `NodeCommandService.Execute` | same | Only on a fused node, same reasoning. |
|
||||||
|
| `localdb_sync.v1.LocalDbSync/Sync` | `hasDriver && LocalDb:SyncListenPort > 0` | Only on a fused node that also sets a sync port — no node in the fleet currently has that shape, but the map is anonymised anyway. |
|
||||||
|
|
||||||
|
**A fused central pair is affected on its OWN dispatch, not only inbound from sites.** `CentralCommunicationActor` dials every enabled `ClusterNode` row, its own pair included, and central's telemetry dialer dials its own pair too — so a login-enabled fused central breaks its own command/telemetry plane, not just the sites'.
|
||||||
|
|
||||||
|
**Mapping mechanics.** All five maps live in one place, `MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices`
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs`) — `Program.cs` calls it once and
|
||||||
|
carries no map sites of its own. Four maps chain `.AllowAnonymous()` directly onto `MapGrpcService<T>()`.
|
||||||
|
The fifth, `MapZbLocalDbSync()`, returns `IEndpointRouteBuilder` rather than an
|
||||||
|
`IEndpointConventionBuilder`, so it cannot take the same chain; it is wrapped in an empty-prefix
|
||||||
|
`app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync()`, which carries the convention without
|
||||||
|
changing the wire route pattern.
|
||||||
|
|
||||||
|
**Three standing guards**, all in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/`, so a future map
|
||||||
|
cannot silently reopen this:
|
||||||
|
|
||||||
|
- `MeshGrpcAnonymousMetadataTests` — an endpoint-metadata census that calls the SAME
|
||||||
|
`MapOtOpcUaMeshGrpcServices` production helper across all three role shapes (admin-only, driver-only,
|
||||||
|
fused) and asserts every mesh endpoint carries `IAllowAnonymous`, with an exact expected count per shape
|
||||||
|
and a scope check that the opt-out never leaks onto an unrelated endpoint.
|
||||||
|
- `MeshGrpcMapConventionTests` — a `Program.cs` source scan asserting zero direct `MapGrpcService</
|
||||||
|
MapZbLocalDbSync(` occurrences and exactly one call to the mapper, so a map added straight into
|
||||||
|
`Program.cs` (bypassing the helper, and therefore invisible to the census) fails `MeshGrpcMapConventionTests`.
|
||||||
|
- `MeshGrpcAuthStackTests` / `MeshGrpcDriverOnlyPipelineTests` drive the mesh endpoints through the REAL
|
||||||
|
`AddOtOpcUaAuth` + `UseAuthentication`/`UseAuthorization` pipeline (`DisableLogin=false`) and the
|
||||||
|
driver-only (no-auth-at-all) shape respectively — the CI-shaped reproduction of the outage, with a
|
||||||
|
correctly-keyed call **succeeding** as the load-bearing assertion (a blocked call and a refused call both
|
||||||
|
"fail"; only a genuine success through the real cookie stack proves the opt-out works) and an
|
||||||
|
un-anonymised negative control proving the fixture's auth stack is actually live.
|
||||||
|
|
||||||
|
**Rig:** `docker-dev/docker-compose.yml`'s central-1/central-2 `Security__Auth__DisableLogin` is
|
||||||
|
`${OTOPCUA_DISABLE_LOGIN:-true}` — default unchanged, but `OTOPCUA_DISABLE_LOGIN=false docker compose
|
||||||
|
... up -d central-1 central-2` drives the rig through the real cookie login (`alice`/`bob`, password
|
||||||
|
`password`, against the shared GLAuth) for exactly this kind of verification, instead of every prior mesh
|
||||||
|
live gate running with the one middleware that breaks production neutralized.
|
||||||
|
|
||||||
|
See `archreview/plans/R3-01-mesh-grpc-allowanonymous-plan.md` (R3-01) for the full verification and design
|
||||||
|
record, and `docs/Configuration.md` §`MeshTransport` / §`ConfigSource`-`ConfigServe` and `docs/Telemetry.md`
|
||||||
|
for the affected listener ports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## OTOPCUA0001 Analyzer — Compile-Time Guard
|
## OTOPCUA0001 Analyzer — Compile-Time Guard
|
||||||
|
|
||||||
Per-capability resilience (retry, timeout, circuit-breaker) is applied by `CapabilityInvoker` in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/`. A driver-capability call made **outside** the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.
|
Per-capability resilience (retry, timeout, circuit-breaker) is applied by `CapabilityInvoker` in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/`. A driver-capability call made **outside** the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ public static class OtOpcUaTelemetry
|
|||||||
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
|
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
|
||||||
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
|
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
|
||||||
|
|
||||||
/// <summary>Inbound operations denied by the Primary data-plane gate (archreview 03/S4) —
|
|
||||||
/// site=write|ack|alarm-emit, reason=secondary|detached|role-unknown.</summary>
|
|
||||||
/// <summary>Archreview 03/S15 — tag refs a driver left <c>BadNoCommunication</c> after it reconnected:
|
/// <summary>Archreview 03/S15 — tag refs a driver left <c>BadNoCommunication</c> after it reconnected:
|
||||||
/// the host marked them Bad on the outage, and the driver never re-published them. Non-zero means a
|
/// the host marked them Bad on the outage, and the driver never re-published them. Non-zero means a
|
||||||
/// driver that reconnects without a full re-publish (a push driver missing its initial push, or a
|
/// driver that reconnects without a full re-publish (a push driver missing its initial push, or a
|
||||||
@@ -115,10 +113,26 @@ public static class OtOpcUaTelemetry
|
|||||||
Meter.CreateCounter<long>("otopcua.driver.comms_latched_refs", unit: "{ref}",
|
Meter.CreateCounter<long>("otopcua.driver.comms_latched_refs", unit: "{ref}",
|
||||||
description: "Refs still BadNoCommunication after their driver reconnected (driver, driver_type).");
|
description: "Refs still BadNoCommunication after their driver reconnected (driver, driver_type).");
|
||||||
|
|
||||||
|
/// <summary>Inbound operations denied by the Primary data-plane gate (archreview 03/S4) —
|
||||||
|
/// site=write|ack|alarm-emit, reason=secondary|detached|role-unknown.</summary>
|
||||||
public static readonly Counter<long> PrimaryGateDenied =
|
public static readonly Counter<long> PrimaryGateDenied =
|
||||||
Meter.CreateCounter<long>("otopcua.redundancy.primary_gate_denied", unit: "{denial}",
|
Meter.CreateCounter<long>("otopcua.redundancy.primary_gate_denied", unit: "{denial}",
|
||||||
description: "Operations denied by the Primary gate (site=write|ack|alarm-emit, reason=secondary|detached|role-unknown).");
|
description: "Operations denied by the Primary gate (site=write|ack|alarm-emit, reason=secondary|detached|role-unknown).");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API-key store lookups the 03/S18 bound intervened on (outcome=timeout|cache_hit).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One counter with an <c>outcome</c> tag rather than two: <c>timeout</c> is the alarm signal that
|
||||||
|
/// the ConfigDb is not answering the OPC UA impersonation callback, and <c>cache_hit</c> is the
|
||||||
|
/// ratio that says whether the TTL is doing anything. A missing <c>cache_hit</c> stream on a busy
|
||||||
|
/// fused central is how you notice <c>Security:ApiKeys:StoreCacheSeconds</c> was set to 0 by
|
||||||
|
/// accident. A plain read-through meters neither — the counter names interventions, not traffic.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly Counter<long> ApiKeyStoreBounded =
|
||||||
|
Meter.CreateCounter<long>("otopcua.opcua.apikey_store.bounded", unit: "{lookup}",
|
||||||
|
description: "API-key store lookups the bound intervened on (outcome=timeout|cache_hit).");
|
||||||
|
|
||||||
// ---------------- Convenience helpers ----------------
|
// ---------------- Convenience helpers ----------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -42,4 +42,23 @@ public sealed class AclOptions
|
|||||||
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
|
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often, in seconds, live monitored items are re-authorized against the clock — in addition
|
||||||
|
/// to on every deployment (arch-review 03/S19).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A subscription is authorized once and then streams, so before this a key that expired by the
|
||||||
|
/// CLOCK kept feeding a live subscription until a deployment happened to advance the generation
|
||||||
|
/// — indefinitely on a fleet that has been deployed once and left running. Every other seam
|
||||||
|
/// re-asks per request and honours <c>ExpiresUtc</c> the moment it passes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>0</c> or negative disables the timer, leaving revalidation deploy-triggered only (the
|
||||||
|
/// pre-R3-05 behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
|
||||||
|
/// pass early-returns when ACL enforcement is off.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public int RevalidationIntervalSeconds { get; set; } = 60;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,29 @@ public sealed class ApiKeyOptions
|
|||||||
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
|
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string Pepper { get; set; } = string.Empty;
|
public string Pepper { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard per-call deadline, in milliseconds, on the API-key store lookup that runs inline on OPC UA
|
||||||
|
/// session activation (arch-review 03/S18). Exceeding it <b>denies</b> the activation and emits a
|
||||||
|
/// distinct Warning plus <c>otopcua.opcua.apikey_store.bounded{outcome=timeout}</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>0</c> or negative disables the deadline, restoring the pre-R3-05 unbounded behaviour. Do not
|
||||||
|
/// do that on a node with a ConfigDb: the SDK raises impersonation synchronously under a
|
||||||
|
/// <c>SessionManager</c>-wide lock, so an unanswered lookup stalls <b>every</b> session activation
|
||||||
|
/// in the server, not just its own.
|
||||||
|
/// </remarks>
|
||||||
|
public int StoreTimeoutMs { get; set; } = 2000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TTL, in seconds, of the read-through cache in front of the API-key store (arch-review 03/S18).
|
||||||
|
/// Bounds how long a revocation authored in the ConfigDb can take to reach a <b>new</b> session
|
||||||
|
/// activation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>0</c> or negative disables the cache, restoring a store read per activation. The window it
|
||||||
|
/// introduces is strictly smaller than the one that already exists for <b>already-open</b> sessions
|
||||||
|
/// on every node kind, which is the next deployment — see <c>docs/security.md</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public int StoreCacheSeconds { get; set; } = 5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composition of the OPC UA data-plane authentication + authorization seams (#520), extracted from
|
||||||
|
/// <c>Program.cs</c> so the role-conditional branches are reachable from a test.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A store decorator registered in one branch of a role conditional is exactly the
|
||||||
|
/// "built but never wired" shape, and <c>Program.cs</c>'s top-level statements cannot be driven by a
|
||||||
|
/// test — so the wiring moved here rather than the guard being written against a comment. The
|
||||||
|
/// branches are unchanged from the ones that lived inline; see <c>ApiKeyStoreRegistrationTests</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public static class DataPlaneAuthRegistration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the shared clock, the node-scoped ACL gate and the role-appropriate API-key store +
|
||||||
|
/// user authenticator.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="hasAdmin">Whether this node carries the <c>admin</c> role (and therefore a ConfigDb).</param>
|
||||||
|
/// <param name="hasDriver">Whether this node carries the <c>driver</c> role.</param>
|
||||||
|
/// <returns>The same collection, for chaining.</returns>
|
||||||
|
public static IServiceCollection AddOtOpcUaDataPlaneAuth(
|
||||||
|
this IServiceCollection services, bool hasAdmin, bool hasDriver)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
// ONE clock for the whole authorization surface. The authorizer, the key-store bound and the
|
||||||
|
// revalidation timer all read time, and DataPlaneAuthorizer already documents what a second,
|
||||||
|
// diverging clock costs: every request denied, with a verdict indistinguishable from "no grant
|
||||||
|
// matched". TryAdd so a test (or a future host) can substitute one before this runs.
|
||||||
|
services.TryAddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
|
// #520 stage 5 — the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
||||||
|
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
|
||||||
|
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
|
||||||
|
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations below
|
||||||
|
// both carry a comment about; an absent registration is a node that will not start.
|
||||||
|
services.AddSingleton<IPermissionEvaluator>(sp =>
|
||||||
|
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
|
||||||
|
services.AddSingleton(sp => new DataPlaneAuthorizer(
|
||||||
|
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
|
||||||
|
sp.GetRequiredService<IPermissionEvaluator>(),
|
||||||
|
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
|
||||||
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>(),
|
||||||
|
sp.GetRequiredService<TimeProvider>()));
|
||||||
|
|
||||||
|
if (hasAdmin)
|
||||||
|
{
|
||||||
|
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
||||||
|
// on the very next activation rather than on the next deployment. Preferred wherever it is
|
||||||
|
// available, including on a fused admin+driver node.
|
||||||
|
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
|
||||||
|
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
|
||||||
|
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
|
||||||
|
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
|
||||||
|
// rule is what keeps the redundancy-singleton registration in Program.cs from recursing into
|
||||||
|
// ActorSystem construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter.
|
||||||
|
//
|
||||||
|
// arch-review 03/S18 — and it is WRAPPED. The lookup runs inline in the SDK's synchronous
|
||||||
|
// impersonation callback, under a SessionManager-wide lock, with CancellationToken.None: an
|
||||||
|
// unanswered ConfigDb query therefore stalls every session activation in the server. The
|
||||||
|
// decorator adds the deadline that bounds that, plus a short TTL that turns a burst of
|
||||||
|
// activations into one round trip. It cannot admit anything the inner store did not.
|
||||||
|
services.AddSingleton<IApiKeyStore>(sp => new BoundedApiKeyStore(
|
||||||
|
new ConfigDbApiKeyStore(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||||
|
ConfigDbApiKeyStore.ResolveClusterId(
|
||||||
|
sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)),
|
||||||
|
sp.GetRequiredService<IOptions<ApiKeyOptions>>(),
|
||||||
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<BoundedApiKeyStore>(),
|
||||||
|
sp.GetRequiredService<TimeProvider>()));
|
||||||
|
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||||
|
|
||||||
|
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
||||||
|
services.AddSingleton<IApiKeyAdminService>(sp => new ApiKeyAdminService(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||||
|
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
|
||||||
|
}
|
||||||
|
else if (hasDriver)
|
||||||
|
{
|
||||||
|
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
|
||||||
|
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
|
||||||
|
// token — safe, but it meant no machine client could authenticate against a site node at all.
|
||||||
|
// Deliberately NOT wrapped by the S18 bound: the artifact store is two dictionary probes on an
|
||||||
|
// in-memory snapshot, so it can neither hang nor be worth caching.
|
||||||
|
services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
|
||||||
|
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
|
||||||
|
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
|
||||||
|
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
|
||||||
|
services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using ZB.MOM.WW.LocalDb.Replication;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the mesh gRPC ingress endpoints (artifact serve, central ack, telemetry stream, node
|
||||||
|
/// command) and the LocalDb sync endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why AllowAnonymous (archreview 03/S14).</b> On an admin-role node
|
||||||
|
/// <c>AddOtOpcUaAuth</c> installs a global <c>FallbackPolicy = RequireAuthenticatedUser</c>
|
||||||
|
/// under the cookie scheme, which the authorization middleware applies to EVERY endpoint
|
||||||
|
/// without explicit authorization metadata — gRPC included. These endpoints authenticate
|
||||||
|
/// with a per-cluster PSK bearer inside a gRPC interceptor, which runs INSIDE the endpoint
|
||||||
|
/// invocation, i.e. after that middleware. Without this opt-out a correctly-keyed node call
|
||||||
|
/// is challenged by the cookie handler before the interceptor's verdict matters: artifact
|
||||||
|
/// fetches fail, deploy acks are dropped, deployments seal TimedOut. Anonymous is safe here
|
||||||
|
/// because all five interceptors fail CLOSED — an unset key rejects every call and the
|
||||||
|
/// presented token is compared with FixedTimeEquals.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Do not "tidy" this into Program.cs.</b> A standing census guard
|
||||||
|
/// (<c>MeshGrpcAnonymousMetadataTests</c>) drives THIS method, so a map that moves back
|
||||||
|
/// inline leaves the guard asserting a shape production no longer has — which is exactly
|
||||||
|
/// how the AdminUI cookie <c>FallbackPolicy</c> silently blocked every mesh gRPC call on a
|
||||||
|
/// login-enabled admin node for seven phases of live gates.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class MeshGrpcEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the mesh gRPC ingress endpoints and the LocalDb sync endpoint, gated on the same
|
||||||
|
/// role + dedicated-port checks <c>Program.cs</c> used inline before this extraction.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="app">The built application to map endpoints on.</param>
|
||||||
|
/// <param name="hasAdmin">Whether this node carries the <c>admin</c> role.</param>
|
||||||
|
/// <param name="hasDriver">Whether this node carries the <c>driver</c> role.</param>
|
||||||
|
/// <param name="syncListenPort">
|
||||||
|
/// The dedicated h2c port bound for the LocalDb passive sync endpoint, or <c>0</c> when it is
|
||||||
|
/// not bound.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="configServeGrpcPort">
|
||||||
|
/// The dedicated h2c port bound for the ConfigServe artifact + central command endpoints, or
|
||||||
|
/// <c>0</c> when it is not bound.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="telemetryListenPort">
|
||||||
|
/// The dedicated h2c port bound for the telemetry stream + node command endpoints, or
|
||||||
|
/// <c>0</c> when it is not bound.
|
||||||
|
/// </param>
|
||||||
|
public static void MapOtOpcUaMeshGrpcServices(
|
||||||
|
this WebApplication app,
|
||||||
|
bool hasAdmin,
|
||||||
|
bool hasDriver,
|
||||||
|
int syncListenPort,
|
||||||
|
int configServeGrpcPort,
|
||||||
|
int telemetryListenPort)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(app);
|
||||||
|
|
||||||
|
// Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a
|
||||||
|
// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be
|
||||||
|
// safe even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey
|
||||||
|
// configured — but not mapping it at all is the stronger default.
|
||||||
|
//
|
||||||
|
// AllowAnonymous (03/S14) via an empty-prefix MapGroup: MapZbLocalDbSync returns
|
||||||
|
// IEndpointRouteBuilder, NOT an IEndpointConventionBuilder, so .AllowAnonymous() cannot be
|
||||||
|
// chained onto it the way the other four maps below are. An empty-prefix group carries the
|
||||||
|
// AllowAnonymous convention to whatever it maps while leaving the gRPC route pattern
|
||||||
|
// byte-identical to the ungrouped map (verified by
|
||||||
|
// LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical).
|
||||||
|
if (hasDriver && syncListenPort > 0)
|
||||||
|
{
|
||||||
|
app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on
|
||||||
|
// admin-role nodes (which hold the central SQL connection) and only when a dedicated h2c
|
||||||
|
// port is bound. The ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes
|
||||||
|
// with no ConfigServe:ApiKey.
|
||||||
|
if (hasAdmin && configServeGrpcPort > 0)
|
||||||
|
{
|
||||||
|
// AllowAnonymous (03/S14): this is every FetchAndCache node's artifact fetch. Without the
|
||||||
|
// opt-out the AdminUI cookie FallbackPolicy 401s/redirects it before
|
||||||
|
// ConfigServeAuthInterceptor's verdict matters — a fleet-wide deploy-plane outage on any
|
||||||
|
// login-enabled admin node.
|
||||||
|
app.MapGrpcService<DeploymentArtifactGrpcService>().AllowAnonymous();
|
||||||
|
|
||||||
|
// Central command ingress (mesh gRPC command plane): the node→central deployment ack. It
|
||||||
|
// rides the SAME listener rather than claiming a port of its own — a second cleartext h2c
|
||||||
|
// port would buy nothing but another firewall rule, and both endpoints are admin-role and
|
||||||
|
// dialled by the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping
|
||||||
|
// it under Dps costs nothing (nothing dials it) whereas gating it would mean flipping the
|
||||||
|
// mode on a running fleet requires a central restart before any node can ack — the same
|
||||||
|
// reasoning that registers CentralCommunicationActor in both modes.
|
||||||
|
// CentralCommandAuthInterceptor (added at AddGrpc in Program.cs) is the ONLY inbound auth
|
||||||
|
// and fail-closes per cluster. AllowAnonymous (03/S14): this is every node's deploy ack,
|
||||||
|
// from every cluster — same reasoning as the artifact fetch above.
|
||||||
|
app.MapGrpcService<CentralCommandGrpcService>().AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by
|
||||||
|
// TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey).
|
||||||
|
//
|
||||||
|
// The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME
|
||||||
|
// driver-role h2c listener rather than claiming a fourth port: central already dials this
|
||||||
|
// node here, both directions are per-cluster-keyed, and a separate port would be one more
|
||||||
|
// thing to open on every site firewall for no isolation gain. Mapping it is what makes
|
||||||
|
// MeshCommandAuthInterceptor live — it is registered in Program.cs and is a pass-through
|
||||||
|
// until a call matches this service's method prefix. It fail-closes with no
|
||||||
|
// MeshTransport:GrpcPsk, so an operator who maps the service without setting a key gets a
|
||||||
|
// closed endpoint, not an open one.
|
||||||
|
if (hasDriver && telemetryListenPort > 0)
|
||||||
|
{
|
||||||
|
// AllowAnonymous (03/S14) on both: on a fused admin+driver node (the docker-dev central
|
||||||
|
// pair, and any production fused central) these sit behind the SAME cookie FallbackPolicy
|
||||||
|
// as the ConfigServe endpoints above and would be 401/redirected identically without the
|
||||||
|
// opt-out — central's own telemetry dialer and node-command dispatch to its own pair.
|
||||||
|
app.MapGrpcService<TelemetryStreamGrpcService>().AllowAnonymous();
|
||||||
|
app.MapGrpcService<NodeCommandGrpcService>().AllowAnonymous();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the node manager's monitored-item revalidation pass on a low-frequency tick, so a key that
|
||||||
|
/// expires by the CLOCK reaches a live subscription without waiting for a deployment (arch-review
|
||||||
|
/// 03/S19).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every request-shaped operation re-checks the calling key's usability per call and so honours
|
||||||
|
/// <c>ExpiresUtc</c> the moment it passes. A subscription is authorized once and then streams, and
|
||||||
|
/// streaming is how machine consumers actually read this server — so before this the only thing
|
||||||
|
/// that could stand a subscription down was a deployment.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>The tick must never throw.</b> An unhandled exception on a timer callback thread takes
|
||||||
|
/// the process down. Every tick is wrapped; a faulting pass is logged and the timer keeps running,
|
||||||
|
/// because the next tick is the recovery.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Each tick gets a fresh memo, and that is the pass's own constraint, not this class's.</b>
|
||||||
|
/// <c>RevalidateMonitoredItems</c> allocates its verdict dictionary per call — see the remarks on
|
||||||
|
/// <c>OtOpcUaNodeManager.OnAuthorityChanged</c>. Nothing here may hoist it across ticks, or an
|
||||||
|
/// expiry landing between two ticks would be served the earlier tick's allow.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It adds no decision logic: the verdict comes from the authorizer's own
|
||||||
|
/// <see cref="TimeProvider"/>, and the stand-down (queue <c>BadUserAccessDenied</c>, then detach)
|
||||||
|
/// and its restore are the pass's, unchanged. The pass early-returns unless ACL enforcement is on,
|
||||||
|
/// so this is inert on the default configuration.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AclRevalidationTimer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Action _revalidate;
|
||||||
|
private readonly ILogger _log;
|
||||||
|
private readonly ITimer? _timer;
|
||||||
|
private volatile bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="AclRevalidationTimer"/> class.</summary>
|
||||||
|
/// <param name="revalidate">The revalidation pass to run on each tick.</param>
|
||||||
|
/// <param name="interval">
|
||||||
|
/// How often to run it. Non-positive arms no timer at all — the deploy-triggered-only behaviour
|
||||||
|
/// that predates 03/S19.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="time">The shared clock; its <c>CreateTimer</c> is what arms the tick.</param>
|
||||||
|
/// <param name="log">The logger.</param>
|
||||||
|
public AclRevalidationTimer(Action revalidate, TimeSpan interval, TimeProvider time, ILogger log)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(revalidate);
|
||||||
|
ArgumentNullException.ThrowIfNull(time);
|
||||||
|
ArgumentNullException.ThrowIfNull(log);
|
||||||
|
|
||||||
|
_revalidate = revalidate;
|
||||||
|
_log = log;
|
||||||
|
|
||||||
|
if (interval <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
// Say so once. A disabled guard that logs nothing is indistinguishable from a guard that is
|
||||||
|
// running and finding nothing, which is precisely the confusion 03/S19 is about.
|
||||||
|
_log.LogInformation(
|
||||||
|
"AclRevalidationTimer: disabled — {Section}:RevalidationIntervalSeconds is not positive, "
|
||||||
|
+ "so live monitored items are re-authorized only when a deployment lands",
|
||||||
|
AclOptions.SectionName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_timer = time.CreateTimer(_ => Tick(), state: null, interval, interval);
|
||||||
|
_log.LogInformation(
|
||||||
|
"AclRevalidationTimer: live monitored items will be re-authorized every {Interval}", interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one revalidation pass, absorbing anything it throws.</summary>
|
||||||
|
/// <remarks>Exposed so a test can drive the callback body deterministically rather than by waiting.</remarks>
|
||||||
|
public void Tick()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_revalidate();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "AclRevalidationTimer: the monitored-item revalidation pass threw; "
|
||||||
|
+ "the timer keeps running and the next tick is the recovery");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
_timer?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,18 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|||||||
/// <b>This runs inline on session activation.</b> The SDK raises <c>ImpersonateUser</c>
|
/// <b>This runs inline on session activation.</b> The SDK raises <c>ImpersonateUser</c>
|
||||||
/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
|
/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
|
||||||
/// stall one request — it serializes every session activation server-wide. That is why
|
/// stall one request — it serializes every session activation server-wide. That is why
|
||||||
/// verification is a single indexed lookup plus one HMAC, and why there is no directory call,
|
/// verification is a single indexed lookup plus one HMAC, with no directory call and no retry —
|
||||||
/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys
|
/// the main practical advantage of keys over the LDAP bind this replaces.
|
||||||
/// over the LDAP bind this replaces.
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>"No network I/O" is TRUE ONLY ON A DRIVER-ONLY NODE</b> (arch-review 03/S18). The claim
|
||||||
|
/// describes <see cref="ArtifactApiKeyStore"/>, which is two dictionary probes on an in-memory
|
||||||
|
/// snapshot. On an <b>admin or fused</b> node <see cref="IApiKeyStore"/> is
|
||||||
|
/// <see cref="ConfigDbApiKeyStore"/>, and a UserName activation costs <b>two</b> EF round trips
|
||||||
|
/// (the key row, then the principal's <c>NodeAcl</c> rows) — inside this same lock. What bounds
|
||||||
|
/// that is <see cref="BoundedApiKeyStore"/>, which wraps it in production; the deadline it applies
|
||||||
|
/// surfaces here as an ordinary throw and is denied by the catch-all below, like any other store
|
||||||
|
/// failure.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Fail closed, and say little.</b> Every rejection returns the same opaque message. The
|
/// <b>Fail closed, and say little.</b> Every rejection returns the same opaque message. The
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounds what an <see cref="IApiKeyStore"/> can cost the OPC UA impersonation callback (arch-review
|
||||||
|
/// 03/S18). Wraps <see cref="ConfigDbApiKeyStore"/> on a node that has a ConfigDb; the in-memory
|
||||||
|
/// <see cref="ArtifactApiKeyStore"/> needs neither half and is deliberately left unwrapped.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why a deadline is the fix and the cache is not.</b> The SDK raises impersonation
|
||||||
|
/// synchronously under a <c>SessionManager</c>-wide lock, so a store that does not answer does not
|
||||||
|
/// stall one activation — it stalls every activation in the server. A cache lowers how OFTEN the
|
||||||
|
/// store is asked; only a deadline bounds how LONG one ask may hold that lock.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Every bounded outcome DENIES.</b> A deadline exceeded, a faulted inner store and a
|
||||||
|
/// cancelled call all surface as an exception into
|
||||||
|
/// <see cref="ApiKeyOpcUaUserAuthenticator"/>'s catch-all, which returns the same opaque denial as
|
||||||
|
/// a bad credential. Nothing here can admit a session the inner store did not authorize — the
|
||||||
|
/// decorator can only return what the inner store returned, or throw.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Faults and timeouts are NEVER cached.</b> Caching a deny would be fail-closed and is
|
||||||
|
/// still wrong: it would extend a ConfigDb outage past the outage, denying valid keys for a
|
||||||
|
/// further TTL after the database came back. Only a COMPLETED lookup — a hit or an authoritative
|
||||||
|
/// miss — is stored.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Entries either cache may hold before it is cleared wholesale.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Wholesale rather than per-entry eviction, for the reason <c>SessionAclDecisionCache</c> already
|
||||||
|
/// documents: picking WHICH entry to drop needs knowledge the cache does not have, and a
|
||||||
|
/// repopulate is cheap and cannot be wrong. The ceiling exists so a key-id guessing flood is a
|
||||||
|
/// bounded memory event — negative results are cached deliberately, so that a flood of unknown ids
|
||||||
|
/// does not become a SQL flood.
|
||||||
|
/// </remarks>
|
||||||
|
public const int MaxCacheEntries = 1024;
|
||||||
|
|
||||||
|
private readonly IApiKeyStore _inner;
|
||||||
|
private readonly IOptions<ApiKeyOptions> _options;
|
||||||
|
private readonly ILogger<BoundedApiKeyStore> _log;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly ConcurrentDictionary<string, Entry<ApiKeyLookup?>> _keys = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, Entry<IReadOnlyList<NodeAcl>>> _grants =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="BoundedApiKeyStore"/> class.</summary>
|
||||||
|
/// <param name="inner">The store being bounded.</param>
|
||||||
|
/// <param name="options">The <c>Security:ApiKeys</c> options carrying the deadline and TTL.</param>
|
||||||
|
/// <param name="log">The logger.</param>
|
||||||
|
/// <param name="time">
|
||||||
|
/// The shared clock. The same instance the authorizer reads — see
|
||||||
|
/// <c>DataPlaneAuthRegistration</c> for why a second clock here would be a hazard rather than a
|
||||||
|
/// detail.
|
||||||
|
/// </param>
|
||||||
|
public BoundedApiKeyStore(
|
||||||
|
IApiKeyStore inner,
|
||||||
|
IOptions<ApiKeyOptions> options,
|
||||||
|
ILogger<BoundedApiKeyStore> log,
|
||||||
|
TimeProvider time)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(inner);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(log);
|
||||||
|
ArgumentNullException.ThrowIfNull(time);
|
||||||
|
|
||||||
|
_inner = inner;
|
||||||
|
_options = options;
|
||||||
|
_log = log;
|
||||||
|
_time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The store being bounded.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Exposed so the composition root's wiring can be asserted. A decorator registered in one branch
|
||||||
|
/// of a role conditional is the "built but never wired" shape, and a guard that cannot see what is
|
||||||
|
/// inside the wrapper cannot tell a correctly-wired node from one wrapping the wrong store.
|
||||||
|
/// </remarks>
|
||||||
|
public IApiKeyStore Inner => _inner;
|
||||||
|
|
||||||
|
/// <summary>The clock this bound's deadline and TTL are measured on.</summary>
|
||||||
|
/// <remarks>Same reason as <see cref="Inner"/>: the shared-clock invariant is only pinnable by reference.</remarks>
|
||||||
|
public TimeProvider Clock => _time;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Keyed by <paramref name="apiKeyId"/> alone. <paramref name="utcNow"/> is deliberately not part
|
||||||
|
/// of the key — it changes on every call, so including it would defeat the cache entirely — but a
|
||||||
|
/// cached hit IS re-checked against it before being served, so the TTL can never resurrect a key
|
||||||
|
/// that has expired by the clock in the meantime. The TTL therefore bounds how stale an
|
||||||
|
/// <c>Enabled</c> revocation may be, and nothing else.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ttl = Ttl;
|
||||||
|
if (ttl > TimeSpan.Zero
|
||||||
|
&& TryServe(_keys, apiKeyId, ttl, out var cached)
|
||||||
|
&& !IsExpired(cached, utcNow))
|
||||||
|
{
|
||||||
|
// Metered HERE rather than inside TryServe: an entry that is inside the TTL but whose key has
|
||||||
|
// since expired is not a hit — it falls through to a store read — so counting it in TryServe
|
||||||
|
// would report cache hits that never happened.
|
||||||
|
MeterCacheHit();
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fresh = await BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (ttl > TimeSpan.Zero) Store(_keys, apiKeyId, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.</remarks>
|
||||||
|
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ttl = Ttl;
|
||||||
|
if (ttl > TimeSpan.Zero && TryServe(_grants, principal, ttl, out var cached))
|
||||||
|
{
|
||||||
|
MeterCacheHit();
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fresh = await BoundAsync(token => _inner.FindGrantsAsync(principal, token), ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (ttl > TimeSpan.Zero) Store(_grants, principal, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The configured TTL; non-positive means the cache is off.</summary>
|
||||||
|
private TimeSpan Ttl => TimeSpan.FromSeconds(_options.Value.StoreCacheSeconds);
|
||||||
|
|
||||||
|
/// <summary>Whether a cached lookup has since expired by the clock.</summary>
|
||||||
|
/// <param name="cached">The cached lookup, possibly null (an authoritative miss).</param>
|
||||||
|
/// <param name="utcNow">The caller's current time.</param>
|
||||||
|
/// <returns><see langword="true"/> when the cached key must not be served.</returns>
|
||||||
|
private static bool IsExpired(ApiKeyLookup? cached, DateTime utcNow) =>
|
||||||
|
cached?.Key.ExpiresUtc is { } expires && expires <= utcNow;
|
||||||
|
|
||||||
|
/// <summary>Counts one lookup the cache actually answered.</summary>
|
||||||
|
private static void MeterCacheHit() =>
|
||||||
|
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair<string, object?>("outcome", "cache_hit"));
|
||||||
|
|
||||||
|
/// <summary>Serves an entry if one is present and still inside the TTL.</summary>
|
||||||
|
/// <typeparam name="T">The cached value type.</typeparam>
|
||||||
|
/// <param name="cache">The cache to read.</param>
|
||||||
|
/// <param name="key">The cache key.</param>
|
||||||
|
/// <param name="ttl">How long an entry stays servable.</param>
|
||||||
|
/// <param name="value">The cached value, when this returns true.</param>
|
||||||
|
/// <returns><see langword="true"/> on a hit.</returns>
|
||||||
|
private bool TryServe<T>(
|
||||||
|
ConcurrentDictionary<string, Entry<T>> cache, string key, TimeSpan ttl, out T value)
|
||||||
|
{
|
||||||
|
if (cache.TryGetValue(key, out var entry) && _time.GetUtcNow() - entry.StampedUtc < ttl)
|
||||||
|
{
|
||||||
|
value = entry.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stores a COMPLETED lookup, clearing wholesale first if the ceiling has been reached.</summary>
|
||||||
|
/// <typeparam name="T">The cached value type.</typeparam>
|
||||||
|
/// <param name="cache">The cache to write.</param>
|
||||||
|
/// <param name="key">The cache key.</param>
|
||||||
|
/// <param name="value">The value to cache.</param>
|
||||||
|
private void Store<T>(ConcurrentDictionary<string, Entry<T>> cache, string key, T value)
|
||||||
|
{
|
||||||
|
if (cache.Count >= MaxCacheEntries) cache.Clear();
|
||||||
|
cache[key] = new Entry<T>(_time.GetUtcNow(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One cached lookup and the instant it was taken.</summary>
|
||||||
|
/// <typeparam name="T">The cached value type.</typeparam>
|
||||||
|
/// <param name="StampedUtc">When the inner store answered.</param>
|
||||||
|
/// <param name="Value">What it answered.</param>
|
||||||
|
private readonly record struct Entry<T>(DateTimeOffset StampedUtc, T Value);
|
||||||
|
|
||||||
|
/// <summary>Runs one inner call under the configured deadline.</summary>
|
||||||
|
/// <typeparam name="T">The inner call's result type.</typeparam>
|
||||||
|
/// <param name="call">The inner call, taking the deadline-linked token.</param>
|
||||||
|
/// <param name="ct">The caller's token — in production always <c>None</c>, see remarks.</param>
|
||||||
|
/// <returns>Whatever the inner store returned.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The deadline is created HERE rather than received from the caller because there is no caller
|
||||||
|
/// to receive one from: <c>OpcUaApplicationHost</c> block-bridges the SDK's synchronous
|
||||||
|
/// impersonation callback with <see cref="CancellationToken.None"/>, so the <c>ct</c> the store
|
||||||
|
/// sees in production is always uncancellable.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>It re-throws rather than returning <see langword="null"/>.</b> <c>null</c> means
|
||||||
|
/// "no such usable key", which the authenticator logs as <i>denied — no enabled, unexpired key
|
||||||
|
/// with id X</i>. A timeout is not that, and an operator reading that line during a ConfigDb
|
||||||
|
/// outage would go hunting for a key problem that does not exist. The distinct Warning below is
|
||||||
|
/// the honest signal; the denial itself is unchanged either way.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<T> BoundAsync<T>(Func<CancellationToken, Task<T>> call, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var timeoutMs = _options.Value.StoreTimeoutMs;
|
||||||
|
if (timeoutMs <= 0) return await call(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// The deadline runs on the INJECTED clock (the TimeProvider ctor overload), not on a plain
|
||||||
|
// CancelAfter, so it is the same TimeProvider the authorizer and the revalidation timer read.
|
||||||
|
// Linked with the caller's token so a genuine caller cancellation is still honoured — and so the
|
||||||
|
// catch below can tell the two apart.
|
||||||
|
using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs), _time);
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await call(cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(
|
||||||
|
1, new KeyValuePair<string, object?>("outcome", "timeout"));
|
||||||
|
_log.LogWarning(
|
||||||
|
"ApiKeyStore: the key store did not answer within {TimeoutMs} ms — denying this activation. "
|
||||||
|
+ "Every OPC UA session activation on this server is serialized behind this call; "
|
||||||
|
+ "check ConfigDb health.",
|
||||||
|
timeoutMs);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,19 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|||||||
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
|
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
|
||||||
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
|
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Reading per request is what makes revocation immediate here, which is why this store is
|
/// Reading per activation is what makes revocation prompt here, which is why this store is
|
||||||
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
|
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node. ⚠️ It reaches a
|
||||||
|
/// <b>new session activation</b> only: an already-open session re-checks its key against the
|
||||||
|
/// in-memory artifact snapshot on every operation, not against this store, so clearing
|
||||||
|
/// <c>Enabled</c> here does not stop an open session until the next deployment. See
|
||||||
|
/// <c>docs/security.md</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>In production this store is WRAPPED by <see cref="BoundedApiKeyStore"/></b> (arch-review
|
||||||
|
/// 03/S18), because these queries run inline in the SDK's impersonation callback under a
|
||||||
|
/// SessionManager-wide lock. A call may therefore be cancelled by the wrapper's deadline even
|
||||||
|
/// though the caller passed <see cref="CancellationToken.None"/> — the resulting
|
||||||
|
/// <see cref="OperationCanceledException"/> is a denial, not a bug.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
|
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
|
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
|
||||||
private readonly DataPlaneAuthorizer? _dataPlaneAuthorizer;
|
private readonly DataPlaneAuthorizer? _dataPlaneAuthorizer;
|
||||||
private readonly ServerHistorianOptions _serverHistorianOptions;
|
private readonly ServerHistorianOptions _serverHistorianOptions;
|
||||||
|
private readonly AclOptions _aclOptions;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
private readonly Func<ActorSystem> _actorSystemAccessor;
|
private readonly Func<ActorSystem> _actorSystemAccessor;
|
||||||
private readonly ActorRegistry _actorRegistry;
|
private readonly ActorRegistry _actorRegistry;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
@@ -45,6 +47,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
|
|
||||||
private OpcUaApplicationHost? _appHost;
|
private OpcUaApplicationHost? _appHost;
|
||||||
private OtOpcUaSdkServer? _server;
|
private OtOpcUaSdkServer? _server;
|
||||||
|
private AclRevalidationTimer? _revalidationTimer;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the OtOpcUaServerHostedService class.
|
/// Initializes a new instance of the OtOpcUaServerHostedService class.
|
||||||
@@ -77,6 +80,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
/// <param name="dataPlaneAuthorizer">#520 stage 5 — the node-scoped ACL gate. Nullable, and null means
|
/// <param name="dataPlaneAuthorizer">#520 stage 5 — the node-scoped ACL gate. Nullable, and null means
|
||||||
/// "not wired" rather than "deny": an unregistered gate must not lock a node out of writes to protect a
|
/// "not wired" rather than "deny": an unregistered gate must not lock a node out of writes to protect a
|
||||||
/// feature that is off by default. Once wired AND enabled, every unresolvable case inside it denies.</param>
|
/// feature that is off by default. Once wired AND enabled, every unresolvable case inside it denies.</param>
|
||||||
|
/// <param name="aclOptions">#520 / arch-review 03/S19 — the <c>Security:Acl</c> options, read for
|
||||||
|
/// <see cref="AclOptions.RevalidationIntervalSeconds"/>. Required, not optional: the built-in DI
|
||||||
|
/// container does not fall back to a default for an unregistered service, the same trap the
|
||||||
|
/// <c>IOpcUaUserAuthenticator</c> and <c>IDataPlaneAuthoritySource</c> parameters carry comments
|
||||||
|
/// about.</param>
|
||||||
|
/// <param name="time">The shared clock — the one instance the authorizer and the API-key store bound
|
||||||
|
/// also read. Its <c>CreateTimer</c> arms the revalidation tick.</param>
|
||||||
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
||||||
public OtOpcUaServerHostedService(
|
public OtOpcUaServerHostedService(
|
||||||
IOptions<OpcUaApplicationHostOptions> options,
|
IOptions<OpcUaApplicationHostOptions> options,
|
||||||
@@ -89,9 +99,16 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
Func<ActorSystem> actorSystemAccessor,
|
Func<ActorSystem> actorSystemAccessor,
|
||||||
ActorRegistry actorRegistry,
|
ActorRegistry actorRegistry,
|
||||||
DataPlaneAuthorizer? dataPlaneAuthorizer,
|
DataPlaneAuthorizer? dataPlaneAuthorizer,
|
||||||
|
IOptions<AclOptions> aclOptions,
|
||||||
|
TimeProvider time,
|
||||||
ILoggerFactory loggerFactory)
|
ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(aclOptions);
|
||||||
|
ArgumentNullException.ThrowIfNull(time);
|
||||||
|
|
||||||
_dataPlaneAuthorizer = dataPlaneAuthorizer;
|
_dataPlaneAuthorizer = dataPlaneAuthorizer;
|
||||||
|
_aclOptions = aclOptions.Value;
|
||||||
|
_time = time;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_deferredSink = deferredSink;
|
_deferredSink = deferredSink;
|
||||||
_deferredServiceLevel = deferredServiceLevel;
|
_deferredServiceLevel = deferredServiceLevel;
|
||||||
@@ -269,9 +286,41 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
|
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is
|
||||||
|
// attached, so the first tick cannot land on a half-wired server.
|
||||||
|
StartRevalidationTimer();
|
||||||
|
|
||||||
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
|
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Arms the clock-driven monitored-item revalidation tick (arch-review 03/S19).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Created unconditionally: the pass it drives early-returns unless the ACL gate is wired AND
|
||||||
|
/// <c>Security:Acl:Enabled</c> is on, so it stays inert on the default configuration, and
|
||||||
|
/// <c>RevalidationIntervalSeconds = 0</c> arms no timer at all.
|
||||||
|
/// <para>
|
||||||
|
/// The node manager is resolved <b>per tick</b> rather than captured: <see cref="_server"/> is
|
||||||
|
/// replaced across a restart, and a captured reference would go on revalidating a dead
|
||||||
|
/// manager's item map — the same detach hazard the authority-source setter documents.
|
||||||
|
/// </para>
|
||||||
|
/// <para>Separate from <see cref="StartAsync"/> so the timer's lifecycle is testable without booting the SDK.</para>
|
||||||
|
/// </remarks>
|
||||||
|
internal void StartRevalidationTimer()
|
||||||
|
{
|
||||||
|
// Dispose before overwriting. StartAsync runs again when a host restarts the service around the
|
||||||
|
// same instance, and an overwritten field would leave the previous timer armed with nothing left
|
||||||
|
// to dispose it — the same dispose-before-overwrite rule the driver reconnect paths carry.
|
||||||
|
_revalidationTimer?.Dispose();
|
||||||
|
|
||||||
|
_revalidationTimer = new AclRevalidationTimer(
|
||||||
|
() => _server?.NodeManager?.RevalidateMonitoredItems(),
|
||||||
|
TimeSpan.FromSeconds(_aclOptions.RevalidationIntervalSeconds),
|
||||||
|
_time,
|
||||||
|
_loggerFactory.CreateLogger<AclRevalidationTimer>());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stops the OPC UA server asynchronously.
|
/// Stops the OPC UA server asynchronously.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -279,6 +328,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// FIRST, ahead of every detach below (arch-review 03/S19). A tick landing on a node manager whose
|
||||||
|
// authority and gate have already been cleared is the shape this whole teardown block exists to
|
||||||
|
// prevent — a stopping node sheds the trigger before it sheds what the trigger drives.
|
||||||
|
_revalidationTimer?.Dispose();
|
||||||
|
_revalidationTimer = null;
|
||||||
|
|
||||||
// Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
|
// Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
|
||||||
// half-disposed NodeManager.
|
// half-disposed NodeManager.
|
||||||
_deferredSink.SetSink(null);
|
_deferredSink.SetSink(null);
|
||||||
@@ -304,6 +359,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
// Before the app host, and idempotent with StopAsync — a host that disposes without stopping must
|
||||||
|
// not leave a tick armed against a server that is about to go away.
|
||||||
|
_revalidationTimer?.Dispose();
|
||||||
|
_revalidationTimer = null;
|
||||||
|
|
||||||
if (_appHost is not null) await _appHost.DisposeAsync();
|
if (_appHost is not null) await _appHost.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,55 +398,10 @@ if (hasDriver)
|
|||||||
builder.Services.AddSingleton<PermissionTrieCache>();
|
builder.Services.AddSingleton<PermissionTrieCache>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// #520 stage 5 — the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
// #520 + arch-review 03/S18 — the node-scoped ACL gate, the shared clock and the role-appropriate
|
||||||
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
|
// API-key store + user authenticator, all in one extension so the role-conditional branches are
|
||||||
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
|
// reachable from a test (Program.cs's top-level statements are not). See DataPlaneAuthRegistration.
|
||||||
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations above
|
builder.Services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
|
||||||
// both carry a comment about; an absent registration is a node that will not start.
|
|
||||||
builder.Services.AddSingleton<IPermissionEvaluator>(sp =>
|
|
||||||
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
|
|
||||||
builder.Services.AddSingleton(sp => new DataPlaneAuthorizer(
|
|
||||||
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
|
|
||||||
sp.GetRequiredService<IPermissionEvaluator>(),
|
|
||||||
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
|
|
||||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>()));
|
|
||||||
|
|
||||||
if (hasAdmin)
|
|
||||||
{
|
|
||||||
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
|
||||||
// on the very next request rather than on the next deployment. Preferred wherever it is available,
|
|
||||||
// including on a fused admin+driver node.
|
|
||||||
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
|
|
||||||
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
|
|
||||||
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
|
|
||||||
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
|
|
||||||
// rule is what keeps the redundancy-singleton registration below from recursing into ActorSystem
|
|
||||||
// construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter (legacy behaviour).
|
|
||||||
builder.Services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
|
|
||||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
|
||||||
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
|
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
|
||||||
|
|
||||||
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
|
||||||
builder.Services.AddSingleton<IApiKeyAdminService>(sp => new ApiKeyAdminService(
|
|
||||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
|
||||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
|
|
||||||
}
|
|
||||||
else if (hasDriver)
|
|
||||||
{
|
|
||||||
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
|
|
||||||
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
|
|
||||||
// token — safe, but it meant no machine client could authenticate against a site node at all.
|
|
||||||
builder.Services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
|
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
|
|
||||||
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
|
|
||||||
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
|
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
||||||
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
|
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
|
||||||
@@ -740,48 +695,12 @@ if (hasAdmin)
|
|||||||
app.MapOtOpcUaDeployApi(app.Configuration);
|
app.MapOtOpcUaDeployApi(app.Configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a
|
// Mesh gRPC ingress: the LocalDb passive sync endpoint, the ConfigServe artifact + central
|
||||||
// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be safe
|
// command endpoints, and the telemetry-stream + node-command endpoints. Extracted to
|
||||||
// even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey configured — but
|
// MeshGrpcEndpoints so a standing census test drives the SAME production mapping (see that
|
||||||
// not mapping it at all is the stronger default.
|
// file's remarks — archreview 03/S14).
|
||||||
if (hasDriver && syncListenPort > 0)
|
app.MapOtOpcUaMeshGrpcServices(
|
||||||
{
|
hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
|
||||||
app.MapZbLocalDbSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on admin-role
|
|
||||||
// nodes (which hold the central SQL connection) and only when a dedicated h2c port is bound. The
|
|
||||||
// ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes with no ConfigServe:ApiKey.
|
|
||||||
if (hasAdmin && configServeGrpcPort > 0)
|
|
||||||
{
|
|
||||||
app.MapGrpcService<DeploymentArtifactGrpcService>();
|
|
||||||
|
|
||||||
// Central command ingress (mesh gRPC command plane): the node→central deployment ack. It
|
|
||||||
// rides the SAME listener rather than claiming a port of its own — a second cleartext h2c port
|
|
||||||
// would buy nothing but another firewall rule, and both endpoints are admin-role and dialled by
|
|
||||||
// the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping it under Dps
|
|
||||||
// costs nothing (nothing dials it) whereas gating it would mean flipping the mode on a running
|
|
||||||
// fleet requires a central restart before any node can ack — the same reasoning that registers
|
|
||||||
// CentralCommunicationActor in both modes. CentralCommandAuthInterceptor (added at AddGrpc
|
|
||||||
// above) is the ONLY inbound auth and fail-closes per cluster.
|
|
||||||
app.MapGrpcService<CentralCommandGrpcService>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by
|
|
||||||
// TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey).
|
|
||||||
//
|
|
||||||
// The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME driver-role
|
|
||||||
// h2c listener rather than claiming a fourth port: central already dials this node here, both
|
|
||||||
// directions are per-cluster-keyed, and a separate port would be one more thing to open on every
|
|
||||||
// site firewall for no isolation gain. Mapping it is what makes MeshCommandAuthInterceptor live —
|
|
||||||
// it is registered above and is a pass-through until a call matches this service's method prefix.
|
|
||||||
// It fail-closes with no MeshTransport:GrpcPsk, so an operator who maps the service without setting
|
|
||||||
// a key gets a closed endpoint, not an open one.
|
|
||||||
if (hasDriver && telemetryListenPort > 0)
|
|
||||||
{
|
|
||||||
app.MapGrpcService<TelemetryStreamGrpcService>();
|
|
||||||
app.MapGrpcService<NodeCommandGrpcService>();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.MapOtOpcUaHealth();
|
app.MapOtOpcUaHealth();
|
||||||
app.MapOtOpcUaMetrics();
|
app.MapOtOpcUaMetrics();
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
<NoWarn>$(NoWarn);NU1608</NoWarn>
|
<NoWarn>$(NoWarn);NU1608</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- OtOpcUaServerHostedService.StartRevalidationTimer is internal: it is a lifecycle seam the
|
||||||
|
tests drive so the ACL revalidation timer (arch-review 03/S19) can be asserted without
|
||||||
|
booting the real OPC UA SDK, not a surface for callers. -->
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka.Hosting"/>
|
<PackageReference Include="Akka.Hosting"/>
|
||||||
<PackageReference Include="Serilog.AspNetCore"/>
|
<PackageReference Include="Serilog.AspNetCore"/>
|
||||||
|
|||||||
@@ -317,12 +317,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// Re-authorizes live subscriptions when a deployment installs a new authorization generation.
|
/// Re-authorizes live subscriptions when a deployment installs a new authorization generation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// ⚠️ <b>This is the only trigger, and the pass's memo depends on that.</b>
|
/// <para>
|
||||||
/// <see cref="IsSubscriptionStillPermitted"/> memoizes key revocation and expiry for the span of one
|
/// <b>There are TWO triggers</b> (arch-review 03/S19): this one, the deployment axis; and the
|
||||||
/// pass — safe only while revalidation is deploy-triggered, because a pass is then a momentary
|
/// host's low-frequency <c>AclRevalidationTimer</c>, the clock axis — a key's
|
||||||
/// event whose verdicts cannot go stale within it. A future periodic trigger must give each tick a
|
/// <c>ExpiresUtc</c> passes with the clock, not with a deployment, so a deploy-only trigger
|
||||||
/// fresh memo (it already does — the dictionary is pass-local) and must not be tempted to reuse one
|
/// left an expired key streaming to a live subscription indefinitely on a fleet that had been
|
||||||
/// across ticks, or an expiry landing between ticks would be served the previous tick's allow.
|
/// deployed once and left running.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>The second trigger is safe only because the memo is pass-local.</b>
|
||||||
|
/// <see cref="IsSubscriptionStillPermitted"/> memoizes key revocation and expiry for the span
|
||||||
|
/// of one pass, which is sound because a pass is a momentary event whose verdicts cannot go
|
||||||
|
/// stale within it — its dictionary is constructed per call. Hoisting it across ticks, to save
|
||||||
|
/// the walk, would serve an expiry that landed between two ticks the earlier tick's allow, and
|
||||||
|
/// re-open exactly the gap the timer closes.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="snapshot">The newly published snapshot; unused — the current state is re-read per item.</param>
|
/// <param name="snapshot">The newly published snapshot; unused — the current state is re-read per item.</param>
|
||||||
private void OnAuthorityChanged(DataPlaneAuthoritySnapshot snapshot) => RevalidateMonitoredItems();
|
private void OnAuthorityChanged(DataPlaneAuthoritySnapshot snapshot) => RevalidateMonitoredItems();
|
||||||
@@ -3414,9 +3423,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This memo does cover the key expiry check that the authorizer's own cache excludes, but only
|
/// This memo does cover the key expiry check that the authorizer's own cache excludes, but only
|
||||||
/// for the duration of one pass — and a subscription is re-checked at a deployment, never on a
|
/// for the duration of one pass. Since arch-review 03/S19 a subscription is re-checked on the
|
||||||
/// timer, so an expiry that lands inside a single pass was already going to be caught by the
|
/// host's low-frequency timer as well as at a deployment, so an expiry that lands inside a
|
||||||
/// next deployment rather than by this one.
|
/// single pass is caught by the next TICK — one interval later, not one deployment later. What
|
||||||
|
/// makes that hold is that the memo is constructed per call and never reused across passes.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool IsSubscriptionStillPermitted(
|
private bool IsSubscriptionStillPermitted(
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ public sealed class DataPlaneAuthorizer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; }
|
public bool Enabled { get; }
|
||||||
|
|
||||||
|
/// <summary>The clock this gate evaluates key expiry against.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Exposed so the composition root's wiring can be asserted by reference. The remarks on
|
||||||
|
/// <c>UserAuthorizationState</c> record what a second, diverging clock costs: every request denied,
|
||||||
|
/// with a verdict indistinguishable from "no grant matched". One registered singleton threaded into
|
||||||
|
/// every consumer is the fix, and a test that cannot see which instance landed here cannot pin it.
|
||||||
|
/// </remarks>
|
||||||
|
public TimeProvider Clock => _time;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
||||||
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static readonly TimeSpan DefaultApplyWedgeTimeout = TimeSpan.FromMinutes(5);
|
public static readonly TimeSpan DefaultApplyWedgeTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>Minimum interval between two supervision reprovisions of the SAME driver child (archreview
|
||||||
|
/// review F3). Akka's default decider restarts a faulting child immediately and without limit, and the
|
||||||
|
/// reprovision hook makes every restart do ShutdownAsync + InitializeAsync + SubscribeAsync — so an
|
||||||
|
/// un-limited hook turns a driver that faults on every value into a reconnect storm against its device.
|
||||||
|
/// Matched to <see cref="DriverInstanceActor.DefaultReconnectInterval"/>: the reprovision drives the same
|
||||||
|
/// connect work, so it should not run more often than an ordinary reconnect attempt.</summary>
|
||||||
|
public static readonly TimeSpan DefaultReprovisionBackoff = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How long after a driver reconnects the 03/S15 recovery audit waits before reporting refs still
|
/// How long after a driver reconnects the 03/S15 recovery audit waits before reporting refs still
|
||||||
/// latched <see cref="OpcUaStatus.BadNoCommunication"/>. Sized so an ordinary poll driver has had
|
/// latched <see cref="OpcUaStatus.BadNoCommunication"/>. Sized so an ordinary poll driver has had
|
||||||
@@ -352,9 +360,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
/// the config (#516). Bounded by the number of driver children with a delta in flight.</summary>
|
/// the config (#516). Bounded by the number of driver children with a delta in flight.</summary>
|
||||||
private readonly Dictionary<CorrelationId, DriverInstanceSpec> _pendingDelta = new();
|
private readonly Dictionary<CorrelationId, DriverInstanceSpec> _pendingDelta = new();
|
||||||
|
|
||||||
/// <summary>The composition from the most-recent apply (set at the END of
|
/// <summary>One driver child's desired subscription payload — exactly the
|
||||||
/// <see cref="PushDesiredSubscriptions"/>). Null until the first apply.</summary>
|
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> the apply's bulk push sent it.</summary>
|
||||||
private AddressSpaceComposition? _lastComposition;
|
/// <param name="ValueRefs">The non-alarm raw tags' RawPaths this driver should keep subscribed.</param>
|
||||||
|
/// <param name="AlarmRefs">The alarm-bearing raw tags' RawPaths that un-gate this driver's
|
||||||
|
/// <see cref="IAlarmSource"/> feed.</param>
|
||||||
|
private readonly record struct DesiredSubscriptionSet(
|
||||||
|
IReadOnlyList<string> ValueRefs,
|
||||||
|
IReadOnlyList<string> AlarmRefs);
|
||||||
|
|
||||||
|
/// <summary>Per-driver desired subscription sets from the most-recent apply. THE authority: the bulk
|
||||||
|
/// push, <see cref="SpawnChild"/> and the supervision-reprovision handler all send from here, so a
|
||||||
|
/// child obtained by ANY route carries the same set. Cleared and repopulated whole on every apply,
|
||||||
|
/// alongside the routing maps, so renames/removals can never leave a stale entry. Empty until the
|
||||||
|
/// first apply — a child spawned before any apply is handed nothing, which is what it should have.
|
||||||
|
/// <para>It is only ever rebuilt by a SUCCESSFUL apply, so after a failed one it deliberately lingers on
|
||||||
|
/// the last-good composition — matching the address space, drivers and subscriptions that also keep
|
||||||
|
/// serving through a failed apply (#485/#486). A respawn or reprovision in that window therefore hands
|
||||||
|
/// the child the last-known-good set, not an empty one.</para></summary>
|
||||||
|
private readonly Dictionary<string, DesiredSubscriptionSet> _desiredByDriver = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
||||||
@@ -430,6 +454,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<string, HashSet<string>> _commsLatched = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, HashSet<string>> _commsLatched = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Per-driver rate limit for the supervision-reprovision hook (see
|
||||||
|
/// <see cref="DefaultReprovisionBackoff"/>).</summary>
|
||||||
|
private readonly TimeSpan _reprovisionBackoff;
|
||||||
|
|
||||||
|
/// <summary>When each driver was last reprovisioned after a supervision restart, so a crash loop is
|
||||||
|
/// paced rather than amplified. Bounded by the number of driver children; an entry for a driver that is
|
||||||
|
/// later removed simply stops being consulted (the handler resolves <c>_children</c> first).</summary>
|
||||||
|
private readonly Dictionary<string, DateTime> _lastReprovisionUtc = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Host of this node's LocalDb replication partner — the only node that holds a copy of this
|
/// Host of this node's LocalDb replication partner — the only node that holds a copy of this
|
||||||
/// node's alarm queue, and therefore the only node it may ever stand down in favour of.
|
/// node's alarm queue, and therefore the only node it may ever stand down in favour of.
|
||||||
@@ -581,6 +614,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
IHistorizationRoleView? historizationRoleView = null,
|
IHistorizationRoleView? historizationRoleView = null,
|
||||||
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
|
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
|
||||||
TimeSpan? applyWedgeTimeout = null,
|
TimeSpan? applyWedgeTimeout = null,
|
||||||
|
TimeSpan? reprovisionBackoff = null,
|
||||||
TimeSpan? commsRecoveryAuditDelay = null) =>
|
TimeSpan? commsRecoveryAuditDelay = null) =>
|
||||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||||
@@ -593,7 +627,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
|
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
|
||||||
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
||||||
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
|
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
|
||||||
sharesHistorizationScopeWithLocal, applyWedgeTimeout, commsRecoveryAuditDelay));
|
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay));
|
||||||
|
|
||||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||||
@@ -683,9 +717,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
IHistorizationRoleView? historizationRoleView = null,
|
IHistorizationRoleView? historizationRoleView = null,
|
||||||
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
|
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
|
||||||
TimeSpan? applyWedgeTimeout = null,
|
TimeSpan? applyWedgeTimeout = null,
|
||||||
|
TimeSpan? reprovisionBackoff = null,
|
||||||
TimeSpan? commsRecoveryAuditDelay = null)
|
TimeSpan? commsRecoveryAuditDelay = null)
|
||||||
{
|
{
|
||||||
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
|
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
|
||||||
|
_reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff;
|
||||||
_commsRecoveryAuditDelay = commsRecoveryAuditDelay ?? DefaultCommsRecoveryAuditDelay;
|
_commsRecoveryAuditDelay = commsRecoveryAuditDelay ?? DefaultCommsRecoveryAuditDelay;
|
||||||
_historizationRoleView = historizationRoleView;
|
_historizationRoleView = historizationRoleView;
|
||||||
_sharesHistorizationScopeWithLocal = sharesHistorizationScopeWithLocal;
|
_sharesHistorizationScopeWithLocal = sharesHistorizationScopeWithLocal;
|
||||||
@@ -1121,6 +1157,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
|
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
||||||
|
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||||
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
||||||
@@ -1177,6 +1215,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
|
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
||||||
|
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||||
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
||||||
@@ -1943,6 +1983,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||||
Receive<RetryConfigDbConnection>(_ => TryRecoverFromStale());
|
Receive<RetryConfigDbConnection>(_ => TryRecoverFromStale());
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
|
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
||||||
|
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
||||||
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
||||||
@@ -2885,23 +2927,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND
|
// Cache the per-driver payload as THE authority, in the same clear-and-repopulate pass as the routing
|
||||||
// the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the
|
// maps above so a rename/removal can never leave a stale entry behind. Keyed over the UNION of the two
|
||||||
// SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained
|
// ref maps AND the running children: a child with no authored tags must get an explicit EMPTY set (which
|
||||||
// invariant. An EMPTY set is valid — the child's Connected handler routes it to Unsubscribe (dropping a
|
// it routes to Unsubscribe), not be absent from the authority — absence and empty must not differ here.
|
||||||
// stale handle) rather than a spurious subscribe. Returns the value-ref count for the bulk-loop log total.
|
_desiredByDriver.Clear();
|
||||||
int SendAuthoredOnly(IActorRef actor, string driverId)
|
foreach (var driverId in refsByDriver.Keys
|
||||||
|
.Concat(alarmRefsByDriver.Keys)
|
||||||
|
.Concat(_children.Keys)
|
||||||
|
.Distinct(StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
var refs = refsByDriver.TryGetValue(driverId, out var r) ? r : Array.Empty<string>();
|
_desiredByDriver[driverId] = new DesiredSubscriptionSet(
|
||||||
var alarmRefs = alarmRefsByDriver.TryGetValue(driverId, out var ar) ? ar : Array.Empty<string>();
|
refsByDriver.TryGetValue(driverId, out var r) ? r : Array.Empty<string>(),
|
||||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(refs, SubscriptionPublishingInterval, alarmRefs));
|
alarmRefsByDriver.TryGetValue(driverId, out var ar) ? ar : Array.Empty<string>());
|
||||||
return refs.Count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = 0;
|
var total = 0;
|
||||||
foreach (var (driverId, entry) in _children)
|
foreach (var (driverId, entry) in _children)
|
||||||
{
|
{
|
||||||
total += SendAuthoredOnly(entry.Actor, driverId);
|
total += SendDesiredSubscriptions(entry.Actor, driverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (total > 0)
|
if (total > 0)
|
||||||
@@ -2936,11 +2980,34 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
_log.Info("DriverHost {Node}: applied {Count} Equipment ScriptedAlarm(s) to the ScriptedAlarm host",
|
_log.Info("DriverHost {Node}: applied {Count} Equipment ScriptedAlarm(s) to the ScriptedAlarm host",
|
||||||
_localNode, composition.EquipmentScriptedAlarms.Count);
|
_localNode, composition.EquipmentScriptedAlarms.Count);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cache the applied composition LAST. Set here (not in HandleArtifactForApply) so both the fresh-apply and
|
/// <summary>
|
||||||
// bootstrap-restore paths — which both route through this method — leave a current composition.
|
/// Send <paramref name="driverId"/>'s desired subscription payload to <paramref name="actor"/>.
|
||||||
_lastComposition = composition;
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The ONE place a <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> is built, so the apply's
|
||||||
|
/// bulk push, a respawn (<see cref="SpawnChild"/>) and a supervision reprovision
|
||||||
|
/// (<c>HandleReprovisionRequested</c>) cannot drift. That is structural rather than
|
||||||
|
/// comment-maintained on purpose: a second sender that assembled its own payload is exactly how
|
||||||
|
/// 03/S16 happened — the restart path got a child without the set that makes a child useful.
|
||||||
|
/// <para>
|
||||||
|
/// An EMPTY set is valid and MEANINGFUL — the child routes it to <c>Unsubscribe</c> (dropping a
|
||||||
|
/// stale handle) rather than to a spurious subscribe — so a driver missing from
|
||||||
|
/// <see cref="_desiredByDriver"/> is sent an empty set, never skipped.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="actor">The driver child to hand the set to.</param>
|
||||||
|
/// <param name="driverId">The driver instance whose set to send.</param>
|
||||||
|
/// <returns>The value-ref count, for the bulk loop's log total.</returns>
|
||||||
|
private int SendDesiredSubscriptions(IActorRef actor, string driverId)
|
||||||
|
{
|
||||||
|
var set = _desiredByDriver.TryGetValue(driverId, out var s)
|
||||||
|
? s
|
||||||
|
: new DesiredSubscriptionSet(Array.Empty<string>(), Array.Empty<string>());
|
||||||
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
||||||
|
set.ValueRefs, SubscriptionPublishingInterval, set.AlarmRefs));
|
||||||
|
return set.ValueRefs.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SpawnChild(DriverInstanceSpec spec)
|
private void SpawnChild(DriverInstanceSpec spec)
|
||||||
@@ -3014,6 +3081,29 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
|
|
||||||
_children[spec.DriverInstanceId] = new ChildEntry(child, spec, stub);
|
_children[spec.DriverInstanceId] = new ChildEntry(child, spec, stub);
|
||||||
|
|
||||||
|
// EVERY spawn hands the child its desired set — deploy reconcile, operator Restart (03/S16), and the
|
||||||
|
// supervision reprovision. Sending it HERE rather than at each call site is the point: a child that
|
||||||
|
// exists without its subscription set is a dead driver, so the two facts are established together.
|
||||||
|
// Before this, the only sender was the apply's bulk push, so an operator's Restart produced a child
|
||||||
|
// that connected and then read nothing until the next deployment — the #488 periodic reconcile cannot
|
||||||
|
// help, because it early-returns on an empty desired set.
|
||||||
|
//
|
||||||
|
// ORDERING, and its one accepted cost: ApplyDriverSpecs (which spawns) runs BEFORE
|
||||||
|
// ApplyDesiredSubscriptions (which rebuilds _desiredByDriver), so on a deploy that RESPAWNS a child
|
||||||
|
// (#516 — the merged DriverConfig folds in the raw tags, so a tag-set edit changes the spec) the new
|
||||||
|
// child is handed the PREVIOUS apply's set, and nothing at all on the first deploy. That transient is
|
||||||
|
// superseded a moment later by the bulk push, on the same sender→receiver pair, so Akka's per-pair
|
||||||
|
// ordering guarantees the child CONVERGES on the new set — but it is NOT guaranteed to be inert on
|
||||||
|
// the way there: the child runs concurrently with the rest of this handler, so a driver whose
|
||||||
|
// InitializeAsync completes quickly can reach Connected and subscribe the stale set before the bulk
|
||||||
|
// push arrives, then re-subscribe. The cost is one extra subscribe/unsubscribe round-trip on a
|
||||||
|
// respawning deploy (HandleSubscribeAsync drops the prior subscription before establishing the new
|
||||||
|
// one, so it converges); a real device driver, whose initialise opens a socket, rarely wins that race.
|
||||||
|
// Deliberately not "fixed" by withholding the send: a child that never gets a set is the 03/S16
|
||||||
|
// defect, and the operator-Restart and supervision paths have no bulk push coming at all.
|
||||||
|
// Sent after InitializeRequested above, which keeps init-before-subscribe ordering.
|
||||||
|
SendDesiredSubscriptions(child, spec.DriverInstanceId);
|
||||||
|
|
||||||
// v3 (WP7): if the (real, non-stub) driver consumes other tags' live values, spawn a mux-adapter
|
// v3 (WP7): if the (real, non-stub) driver consumes other tags' live values, spawn a mux-adapter
|
||||||
// child that registers its dependency refs on the per-node dependency mux and forwards changes
|
// child that registers its dependency refs on the per-node dependency mux and forwards changes
|
||||||
// into the driver. Requires a wired mux (dev/None deployments have none — then calc inputs simply
|
// into the driver. Requires a wired mux (dev/None deployments have none — then calc inputs simply
|
||||||
@@ -3164,6 +3254,87 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
SpawnChild(entry.Spec);
|
SpawnChild(entry.Spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-provision a driver child that Akka's supervision just RESTARTED in place (archreview 03/S6,
|
||||||
|
/// 03/S16) — the third and least visible of the three incarnation routes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A restart recreates the actor object around the SAME driver instance (the Props closure captured
|
||||||
|
/// it) without going through <see cref="SpawnChild"/>, so neither the <c>InitializeRequested</c> nor
|
||||||
|
/// the desired-subscription send that a spawn performs ever happens — and the child's
|
||||||
|
/// <see cref="IActorRef"/> is unchanged, so <c>_children</c> needs no maintenance and the host has no
|
||||||
|
/// other notification that a new incarnation exists. Its <c>PreRestart</c> ran <c>PostStop</c>, which
|
||||||
|
/// called <c>ShutdownAsync</c> on the driver, so re-initialising is not optional: without it the
|
||||||
|
/// child sits in <c>Connecting</c> forever around a shut-down driver.
|
||||||
|
/// <para>
|
||||||
|
/// The dependency-consumer mux adapter is a SEPARATE child of this host and is unaffected by the
|
||||||
|
/// instance actor restarting, so it is deliberately not touched here.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="msg">The restarted child's self-announcement.</param>
|
||||||
|
private void HandleReprovisionRequested(DriverInstanceActor.ReprovisionRequested msg) =>
|
||||||
|
ReprovisionChild(msg, Sender);
|
||||||
|
|
||||||
|
/// <summary>A reprovision held back by <see cref="_reprovisionBackoff"/>, carrying the incarnation it
|
||||||
|
/// was requested by so the identity check still applies when it is re-delivered by the timer.</summary>
|
||||||
|
/// <param name="Request">The original request.</param>
|
||||||
|
/// <param name="Child">The incarnation that asked; re-validated against <c>_children</c> on delivery,
|
||||||
|
/// so a driver restarted or removed in the meantime discards it.</param>
|
||||||
|
private sealed record ReprovisionDeferred(
|
||||||
|
DriverInstanceActor.ReprovisionRequested Request, IActorRef Child);
|
||||||
|
|
||||||
|
private static string ReprovisionTimerKey(string driverInstanceId) => "reprovision-" + driverInstanceId;
|
||||||
|
|
||||||
|
private void ReprovisionChild(DriverInstanceActor.ReprovisionRequested msg, IActorRef child)
|
||||||
|
{
|
||||||
|
// Already stopped (a restart racing a reconcile that removed the driver) — nothing to provision.
|
||||||
|
if (!_children.TryGetValue(msg.DriverInstanceId, out var entry)) return;
|
||||||
|
|
||||||
|
// Route by INCARNATION, not just by id. This message is delivered asynchronously, so one can still
|
||||||
|
// be in flight from an OLD incarnation after HandleRestartDriver stopped that child and spawned a
|
||||||
|
// replacement under the same DriverInstanceId. Delivered to the new child — which is in Connecting,
|
||||||
|
// where InitializeRequested IS handled — it would start a second, concurrent InitializeAsync,
|
||||||
|
// racing the dispose-before-overwrite that #527's teardown exists to serialise
|
||||||
|
// (ModbusDriver._transport, MqttDriver._connection each leak a transport on that race).
|
||||||
|
if (!Equals(child, entry.Actor))
|
||||||
|
{
|
||||||
|
_log.Debug(
|
||||||
|
"DriverHost {Node}: dropping a reprovision for {Id} from a superseded incarnation ({Stale})",
|
||||||
|
_localNode, msg.DriverInstanceId, child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit per driver (review F3). DEFERRED, never dropped: the last request must still be
|
||||||
|
// honoured, or a child whose final crash fell inside the window is left un-provisioned forever —
|
||||||
|
// exactly the 03/S16 failure this hook exists to close. One timer key per driver, so a burst
|
||||||
|
// collapses to a single pending reprovision rather than a queue of them.
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (_lastReprovisionUtc.TryGetValue(msg.DriverInstanceId, out var last) &&
|
||||||
|
now - last < _reprovisionBackoff)
|
||||||
|
{
|
||||||
|
var delay = _reprovisionBackoff - (now - last);
|
||||||
|
_log.Warning(
|
||||||
|
"DriverHost {Node}: driver child {Id} was restarted by supervision again after {Reason} " +
|
||||||
|
"within {Backoff} — deferring the re-provision by {Delay} (crash loop?)",
|
||||||
|
_localNode, msg.DriverInstanceId, msg.Reason, _reprovisionBackoff, delay);
|
||||||
|
Timers.StartSingleTimer(
|
||||||
|
ReprovisionTimerKey(msg.DriverInstanceId), new ReprovisionDeferred(msg, child), delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastReprovisionUtc[msg.DriverInstanceId] = now;
|
||||||
|
|
||||||
|
// A crash that Akka silently restarted is otherwise invisible to operators; name it.
|
||||||
|
_log.Warning(
|
||||||
|
"DriverHost {Node}: driver child {Id} was restarted by supervision after {Reason} — re-provisioning",
|
||||||
|
_localNode, msg.DriverInstanceId, msg.Reason);
|
||||||
|
|
||||||
|
// A stub child has no real driver to initialise (its Stubbed behaviour no-ops InitializeRequested
|
||||||
|
// anyway), but it still stores a desired set, so only the initialise is conditional.
|
||||||
|
if (!entry.Stubbed) entry.Actor.Tell(new DriverInstanceActor.InitializeRequested(entry.Spec.DriverConfig));
|
||||||
|
SendDesiredSubscriptions(entry.Actor, msg.DriverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleReconnectDriver(ReconnectDriver msg)
|
private void HandleReconnectDriver(ReconnectDriver msg)
|
||||||
{
|
{
|
||||||
// DPS broadcast — only act if this node hosts the requested instance.
|
// DPS broadcast — only act if this node hosts the requested instance.
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
|
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
|
||||||
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
|
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
|
||||||
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sent to the parent from <see cref="PostRestart"/> so the host re-provisions THIS incarnation
|
||||||
|
/// (archreview 03/S6, 03/S16).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A supervision restart recreates the actor object in place: the desired subscription sets are gone
|
||||||
|
/// (they are instance fields), and <c>PreRestart</c> has already run <see cref="PostStop"/> — which
|
||||||
|
/// called <see cref="IDriver.ShutdownAsync"/> on the driver object the Props closure re-uses. Nothing
|
||||||
|
/// else re-sends <see cref="InitializeRequested"/>: its only sender is
|
||||||
|
/// <c>DriverHostActor.SpawnChild</c>, and a restart does NOT respawn (the child's
|
||||||
|
/// <see cref="IActorRef"/> is unchanged, so the host is not otherwise notified that a new incarnation
|
||||||
|
/// exists at all). Without this the restarted child sits in <c>Connecting</c> forever around a
|
||||||
|
/// shut-down driver, with nothing subscribed — silently, since Akka logs the fault but the recovery
|
||||||
|
/// is invisible.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="DriverInstanceId">The driver instance that was restarted.</param>
|
||||||
|
/// <param name="Reason">The type name of the exception that tripped supervision, for the host's log.</param>
|
||||||
|
public sealed record ReprovisionRequested(string DriverInstanceId, string Reason);
|
||||||
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
|
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
|
||||||
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -252,6 +271,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// idempotent across repeated <see cref="SetDesiredSubscriptions"/> pushes.</summary>
|
/// idempotent across repeated <see cref="SetDesiredSubscriptions"/> pushes.</summary>
|
||||||
private IAlarmSubscriptionHandle? _alarmSubscriptionHandle;
|
private IAlarmSubscriptionHandle? _alarmSubscriptionHandle;
|
||||||
|
|
||||||
|
/// <summary>The alarm references the live <see cref="_alarmSubscriptionHandle"/> was actually established
|
||||||
|
/// with, so a CHANGED desired set can be told apart from a repeat of the same one.
|
||||||
|
/// <para>Without this the subscription latched: both <see cref="SubscribeDesiredAlarms"/> and
|
||||||
|
/// <see cref="HandleSubscribeAlarmsAsync"/> gate on "a handle already exists", which is the right guard
|
||||||
|
/// for an idempotent re-push and the WRONG one for a deploy that widens or narrows the alarm set —
|
||||||
|
/// drivers that filter on <c>sourceNodeIds</c> (OPC UA Client, AB CIP, FOCAS) would then never deliver a
|
||||||
|
/// newly-added alarm for the life of the connection. Empty whenever no handle is held.</para></summary>
|
||||||
|
private IReadOnlyList<string> _subscribedAlarmRefs = Array.Empty<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the timer scheduler for scheduling reconnection attempts.
|
/// Gets or sets the timer scheduler for scheduling reconnection attempts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -375,6 +403,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Ask the host to re-provision this incarnation — see <see cref="ReprovisionRequested"/> for why a
|
||||||
|
/// supervision restart otherwise leaves a dead child. <c>base.PostRestart</c> runs first because it
|
||||||
|
/// runs <see cref="PreStart"/>, which re-attaches the rediscovery / host-status sources and restarts
|
||||||
|
/// the health timer; the reprovision is the part <see cref="PreStart"/> cannot do, since only the
|
||||||
|
/// host knows this driver's config and desired set.
|
||||||
|
/// </remarks>
|
||||||
|
protected override void PostRestart(Exception reason)
|
||||||
|
{
|
||||||
|
base.PostRestart(reason);
|
||||||
|
Context.Parent.Tell(new ReprovisionRequested(_driverInstanceId, reason.GetType().Name));
|
||||||
|
}
|
||||||
|
|
||||||
private void Stubbed()
|
private void Stubbed()
|
||||||
{
|
{
|
||||||
// Stubbed drivers accept the standard message contracts but return deterministic
|
// Stubbed drivers accept the standard message contracts but return deterministic
|
||||||
@@ -488,10 +530,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
// Native-alarm analogue: un-gate the IAlarmSource feed when alarm tags are (now) present. The
|
// Native-alarm analogue: un-gate the IAlarmSource feed when alarm tags are (now) present. The
|
||||||
// common live path — a deploy delivers SetDesiredSubscriptions while the driver is already
|
// common live path — a deploy delivers SetDesiredSubscriptions while the driver is already
|
||||||
// Connected — flows through HERE, so the alarm subscribe must happen on this message, not only
|
// Connected — flows through HERE, so the alarm subscribe must happen on this message, not only
|
||||||
// on Connected entry. Unlike the value path above, there is deliberately no unsubscribe-on-empty:
|
// on Connected entry. A CHANGED set re-subscribes (release-then-subscribe inside
|
||||||
// a removed alarm tag's condition node is torn down on the address-space rebuild and
|
// HandleSubscribeAlarmsAsync): unlike the value path, "a subscription already exists" is NOT
|
||||||
// DriverHostActor.ForwardNativeAlarm drops any transition whose ConditionId no longer maps, so a
|
// sufficient, because several drivers filter their feed on the subscribed sourceNodeIds, so a
|
||||||
// lingering session-less alarm subscription can never surface a removed alarm.
|
// widened set that never re-subscribed would silently never deliver the new alarm.
|
||||||
|
// Still deliberately no unsubscribe-on-EMPTY: a removed alarm tag's condition node is torn down
|
||||||
|
// on the address-space rebuild and DriverHostActor.ForwardNativeAlarm drops any transition whose
|
||||||
|
// ConditionId no longer maps, so a lingering session-less alarm subscription can never surface a
|
||||||
|
// removed alarm.
|
||||||
SubscribeDesiredAlarms();
|
SubscribeDesiredAlarms();
|
||||||
});
|
});
|
||||||
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
|
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
|
||||||
@@ -561,6 +607,19 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// state on sustained failure: the nine poll drivers via Degraded, MQTT via its live connection
|
/// state on sustained failure: the nine poll drivers via Degraded, MQTT via its live connection
|
||||||
/// state, Galaxy via its reconnect supervisor.
|
/// state, Galaxy via its reconnect supervisor.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The grace is per CONNECTION (03/S17).</b> The silence baseline is
|
||||||
|
/// <c>max(LastSuccessfulRead, _connectedSinceUtc)</c>, not <c>LastSuccessfulRead ?? _connectedSinceUtc</c>.
|
||||||
|
/// Because <c>LastSuccessfulRead</c> is <em>preserved</em> across Degraded/Faulted writes (see above),
|
||||||
|
/// a device that accepts a connection but serves no data reconnects carrying a stamp that is already
|
||||||
|
/// older than the threshold, so a <c>??</c> baseline re-trips on the FIRST health tick of every new
|
||||||
|
/// connection — a reconnect cycle at health-tick cadence rather than the designed threshold. Taking
|
||||||
|
/// the later of the two gives each connection its own full window, which is exactly what re-stamping
|
||||||
|
/// <c>_connectedSinceUtc</c> on every Connected entry was for. Note this <b>paces</b> the cycle, it
|
||||||
|
/// does not end it: a permanently half-broken device still reconnects once per threshold, and a
|
||||||
|
/// driver that stops reading <i>within</i> a stable connection still trips on schedule (its read
|
||||||
|
/// stamp is the later term, exactly as before).
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="health">The health observed on this tick, or null when <see cref="IDriver.GetHealth"/>
|
/// <param name="health">The health observed on this tick, or null when <see cref="IDriver.GetHealth"/>
|
||||||
/// threw — a failed observation is not evidence of comms loss, so it never advances the watchdog.</param>
|
/// threw — a failed observation is not evidence of comms loss, so it never advances the watchdog.</param>
|
||||||
@@ -570,8 +629,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
if (health is not { } observed) return;
|
if (health is not { } observed) return;
|
||||||
if (observed.State == DriverState.Healthy) return;
|
if (observed.State == DriverState.Healthy) return;
|
||||||
|
|
||||||
// A driver that has never reported a successful read is measured from the current Connected entry.
|
// Measure silence from the LATER of the last successful read and THIS connect — the grace is per
|
||||||
var lastEvidence = observed.LastSuccessfulRead ?? _connectedSinceUtc;
|
// connection. `??` was wrong: drivers PRESERVE LastSuccessfulRead across Degraded/Faulted writes (that
|
||||||
|
// preservation is the #527 design), so after a trip + reconnect to a device that accepts the connection
|
||||||
|
// but still serves no data, the stamp is already older than the threshold and the FIRST tick of the new
|
||||||
|
// connection re-trips — churn at health-tick cadence instead of the designed grace. `??` only ever helped
|
||||||
|
// a driver that had never read at all.
|
||||||
|
var lastEvidence = observed.LastSuccessfulRead is { } read && read > _connectedSinceUtc
|
||||||
|
? read
|
||||||
|
: _connectedSinceUtc;
|
||||||
var silence = DateTime.UtcNow - lastEvidence;
|
var silence = DateTime.UtcNow - lastEvidence;
|
||||||
if (silence < _commsLossTimeout) return;
|
if (silence < _commsLossTimeout) return;
|
||||||
|
|
||||||
@@ -1114,6 +1180,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
// (GalaxyDriver.SubscribeAlarmsAsync clears the set before adding), so handles no longer accumulate
|
// (GalaxyDriver.SubscribeAlarmsAsync clears the set before adding), so handles no longer accumulate
|
||||||
// across reconnects. The gate (Count > 0) and the one-shot fan-out are unchanged.
|
// across reconnects. The gate (Count > 0) and the one-shot fan-out are unchanged.
|
||||||
_alarmSubscriptionHandle = null;
|
_alarmSubscriptionHandle = null;
|
||||||
|
_subscribedAlarmRefs = Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Establish the native-alarm subscription that un-gates an <see cref="IAlarmSource"/> driver's
|
/// <summary>Establish the native-alarm subscription that un-gates an <see cref="IAlarmSource"/> driver's
|
||||||
@@ -1123,10 +1190,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// desired, and no subscription is yet established.</summary>
|
/// desired, and no subscription is yet established.</summary>
|
||||||
private void SubscribeDesiredAlarms()
|
private void SubscribeDesiredAlarms()
|
||||||
{
|
{
|
||||||
if (_driver is IAlarmSource && _desiredAlarmRefs.Count > 0 && _alarmSubscriptionHandle is null)
|
if (_driver is IAlarmSource && _desiredAlarmRefs.Count > 0 && AlarmSubscriptionIsStale())
|
||||||
Self.Tell(new SubscribeAlarms());
|
Self.Tell(new SubscribeAlarms());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the desired alarm refs are not the ones the live handle was established with —
|
||||||
|
/// either because there is no handle yet, or because a deploy changed the set. The second case is the
|
||||||
|
/// one a bare "handle is null" check misses; see <see cref="_subscribedAlarmRefs"/>.</summary>
|
||||||
|
private bool AlarmSubscriptionIsStale() =>
|
||||||
|
_alarmSubscriptionHandle is null ||
|
||||||
|
!_subscribedAlarmRefs.SequenceEqual(_desiredAlarmRefs, StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>Calls the driver's <see cref="IAlarmSource.SubscribeAlarmsAsync"/> (bounded) to register the
|
/// <summary>Calls the driver's <see cref="IAlarmSource.SubscribeAlarmsAsync"/> (bounded) to register the
|
||||||
/// alarm subscription that un-gates its native feed, caching the returned handle. Re-checks the guard
|
/// alarm subscription that un-gates its native feed, caching the returned handle. Re-checks the guard
|
||||||
/// (the desired set may have cleared, or another SubscribeAlarms may have already established a handle,
|
/// (the desired set may have cleared, or another SubscribeAlarms may have already established a handle,
|
||||||
@@ -1134,10 +1208,36 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// stays gated until then.</summary>
|
/// stays gated until then.</summary>
|
||||||
private async Task HandleSubscribeAlarmsAsync(SubscribeAlarms _)
|
private async Task HandleSubscribeAlarmsAsync(SubscribeAlarms _)
|
||||||
{
|
{
|
||||||
if (_driver is not IAlarmSource src || _desiredAlarmRefs.Count == 0 || _alarmSubscriptionHandle is not null)
|
if (_driver is not IAlarmSource src || _desiredAlarmRefs.Count == 0 || !AlarmSubscriptionIsStale())
|
||||||
return;
|
return;
|
||||||
var refs = _desiredAlarmRefs;
|
var refs = _desiredAlarmRefs;
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
// A handle held against a DIFFERENT set must be released before re-subscribing, or a
|
||||||
|
// sourceNodeIds-filtering driver accumulates two overlapping subscriptions. Best-effort and
|
||||||
|
// deliberately non-fatal: the re-subscribe below is what actually matters, and a driver whose
|
||||||
|
// unsubscribe throws must not be left on the stale set because of it. Cleared FIRST so a throw
|
||||||
|
// cannot leave a handle that the staleness check would keep treating as live.
|
||||||
|
if (_alarmSubscriptionHandle is { } stale)
|
||||||
|
{
|
||||||
|
_alarmSubscriptionHandle = null;
|
||||||
|
_subscribedAlarmRefs = Array.Empty<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _invoker.ExecuteAsync(
|
||||||
|
DriverCapability.AlarmSubscribe,
|
||||||
|
_driverInstanceId,
|
||||||
|
async ct => await src.UnsubscribeAlarmsAsync(stale, ct),
|
||||||
|
cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Warning(ex,
|
||||||
|
"DriverInstance {Id}: releasing the previous native-alarm subscription ({Diag}) threw; re-subscribing anyway",
|
||||||
|
_driverInstanceId, stale.DiagnosticId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_alarmSubscriptionHandle = await _invoker.ExecuteAsync(
|
_alarmSubscriptionHandle = await _invoker.ExecuteAsync(
|
||||||
@@ -1145,6 +1245,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
_driverInstanceId,
|
_driverInstanceId,
|
||||||
async ct => await src.SubscribeAlarmsAsync(refs, ct),
|
async ct => await src.SubscribeAlarmsAsync(refs, ct),
|
||||||
cts.Token);
|
cts.Token);
|
||||||
|
_subscribedAlarmRefs = refs;
|
||||||
_log.Info("DriverInstance {Id}: native-alarm subscription established for {Count} alarm ref(s) ({Diag})",
|
_log.Info("DriverInstance {Id}: native-alarm subscription established for {Count} alarm ref(s) ({Diag})",
|
||||||
_driverInstanceId, refs.Count, _alarmSubscriptionHandle.DiagnosticId);
|
_driverInstanceId, refs.Count, _alarmSubscriptionHandle.DiagnosticId);
|
||||||
}
|
}
|
||||||
|
|||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-01 (T1-T3) — an endpoint-metadata census over <c>MapOtOpcUaMeshGrpcServices</c>, driven in
|
||||||
|
/// all three role shapes a node can actually carry (admin-only, driver-only, fused).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why this drives the PRODUCTION mapper, not a hand-copied shape.</b> The Critical this
|
||||||
|
/// plan fixes (archreview 03/S14) survived seven phases of live gates precisely because every
|
||||||
|
/// prior test built its own pipeline by hand — <c>MeshGrpcBoundaryTests</c>,
|
||||||
|
/// <c>ConfigServeListenerTests</c> and <c>LocalDbSyncListenerTests</c> all reproduce the
|
||||||
|
/// author's mental model of the mapping rather than the host's actual composition. This
|
||||||
|
/// census calls <see cref="MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices"/> — the SAME method
|
||||||
|
/// <c>Program.cs</c> calls — so it is structurally impossible for the guard to assert a shape
|
||||||
|
/// production no longer has.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Filters out grpc-dotnet's own catch-all infrastructure.</b> Every <c>MapGrpcService</c>
|
||||||
|
/// call also registers a per-service "Unimplemented method for X" fallback, and the FIRST
|
||||||
|
/// such call in an app additionally registers one shared "Unimplemented service" catch-all.
|
||||||
|
/// Neither ever reaches real logic (they always answer <c>Unimplemented</c> regardless of
|
||||||
|
/// caller identity), so their authorization metadata carries no security meaning.
|
||||||
|
/// <see cref="RealMethodEndpoints"/> keeps only the five real, callable RPC methods, matched
|
||||||
|
/// against the exact known route-pattern set rather than a route-pattern-shape heuristic —
|
||||||
|
/// see that method's remarks for why a shape-based filter is NOT safe once LocalDbSync's
|
||||||
|
/// empty-prefix <c>MapGroup</c> (T3) is in play.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Guard-of-the-guard.</b> Every assertion below pins an EXACT expected count and the
|
||||||
|
/// exact set of route patterns, never just "not empty" — a census that silently found zero
|
||||||
|
/// endpoints (a typo'd role gate, a helper that never got called) would pass a bare
|
||||||
|
/// non-empty check trivially. It cannot pass an exact-count-and-set check by accident.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MeshGrpcAnonymousMetadataTests
|
||||||
|
{
|
||||||
|
private const string ArtifactFetch = "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
|
||||||
|
private const string CentralReportApply = "/mesh_transport.v1.CentralCommandService/ReportApply";
|
||||||
|
private const string TelemetrySubscribe = "/telemetry.v1.TelemetryStreamService/Subscribe";
|
||||||
|
private const string NodeExecute = "/mesh_transport.v1.NodeCommandService/Execute";
|
||||||
|
private const string LocalDbSyncSync = "/localdb_sync.v1.LocalDbSync/Sync";
|
||||||
|
|
||||||
|
/// <summary>The five known mesh gRPC method prefixes — the ONLY endpoints this map may anonymise.</summary>
|
||||||
|
private static readonly string[] AllFivePrefixes =
|
||||||
|
[LocalDbSyncSync, ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdminOnly_MapsExactlyTheConfigServeEndpoints()
|
||||||
|
{
|
||||||
|
// Admin-only: no driver role, so neither LocalDbSync nor the telemetry/node-command pair are
|
||||||
|
// mapped even though a port is configured for them below (0 here, but the shape is the point).
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: true, hasDriver: false,
|
||||||
|
syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
|
||||||
|
|
||||||
|
endpoints.Count.ShouldBe(2);
|
||||||
|
endpoints.Select(RoutePatternOf).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriverOnly_WithNoSyncPort_MapsExactlyTheTelemetryAndNodeCommandEndpoints()
|
||||||
|
{
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: false, hasDriver: true,
|
||||||
|
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
|
||||||
|
|
||||||
|
endpoints.Count.ShouldBe(2);
|
||||||
|
endpoints.Select(RoutePatternOf).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint()
|
||||||
|
{
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: false, hasDriver: true,
|
||||||
|
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 4056));
|
||||||
|
|
||||||
|
endpoints.Count.ShouldBe(3);
|
||||||
|
endpoints.Select(RoutePatternOf).ShouldBe(
|
||||||
|
[LocalDbSyncSync, TelemetrySubscribe, NodeExecute], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fused_WithAllThreePorts_MapsAllFiveEndpoints()
|
||||||
|
{
|
||||||
|
// The docker-dev central pair's shape (admin,driver,cluster-MAIN) once all three dedicated
|
||||||
|
// h2c ports are configured — the shape 03/S14 called out as breaking the deploy plane for
|
||||||
|
// the whole fleet, not only the four sites.
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: true, hasDriver: true,
|
||||||
|
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
|
||||||
|
|
||||||
|
endpoints.Count.ShouldBe(5);
|
||||||
|
endpoints.Select(RoutePatternOf).ShouldBe(AllFivePrefixes, ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeitherRoleNorAnyPort_MapsNothing()
|
||||||
|
{
|
||||||
|
// The default-OFF shape: every dedicated port is 0. Nothing about the mesh gRPC surface
|
||||||
|
// should exist on a node that configured none of it.
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: false, hasDriver: false,
|
||||||
|
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 0));
|
||||||
|
|
||||||
|
endpoints.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// 03/S14 — every mesh endpoint carries IAllowAnonymous, so the AdminUI cookie FallbackPolicy
|
||||||
|
// (RequireAuthenticatedUser) never reaches it; the PSK interceptors are the ONLY gate. T3
|
||||||
|
// (LocalDbSync's empty-prefix group) is asserted separately below.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdminOnly_BothConfigServeEndpoints_CarryAllowAnonymous()
|
||||||
|
{
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: true, hasDriver: false,
|
||||||
|
syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
|
||||||
|
|
||||||
|
AnonymousPatternsOf(endpoints).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriverOnly_BothTelemetryAndNodeCommandEndpoints_CarryAllowAnonymous()
|
||||||
|
{
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: false, hasDriver: true,
|
||||||
|
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
|
||||||
|
|
||||||
|
AnonymousPatternsOf(endpoints).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fused_AllFiveMeshEndpoints_CarryAllowAnonymous()
|
||||||
|
{
|
||||||
|
// Includes LocalDbSync (T3): MapZbLocalDbSync returns IEndpointRouteBuilder, not an
|
||||||
|
// IEndpointConventionBuilder, so .AllowAnonymous() cannot be chained onto it the way the
|
||||||
|
// other four are — it needs the empty-prefix MapGroup wrapper instead.
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: true, hasDriver: true,
|
||||||
|
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
|
||||||
|
|
||||||
|
AnonymousPatternsOf(endpoints).ShouldBe(AllFivePrefixes, ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical()
|
||||||
|
{
|
||||||
|
// The empty-prefix MapGroup wrapper (T3) must be pure convention-carrying — it must not
|
||||||
|
// change the gRPC route pattern LocalDbSyncAuthInterceptor and the sync client both key on.
|
||||||
|
var endpoints = RealMethodEndpoints(BuildApp(
|
||||||
|
hasAdmin: false, hasDriver: true,
|
||||||
|
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 0));
|
||||||
|
|
||||||
|
var sync = endpoints.ShouldHaveSingleItem();
|
||||||
|
sync.RoutePattern.RawText.ShouldBe(LocalDbSyncSync);
|
||||||
|
sync.Metadata.GetMetadata<IAllowAnonymous>().ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowAnonymous_NeverLeaksBeyondTheFiveKnownMeshEndpoints()
|
||||||
|
{
|
||||||
|
// Scope guard (theme 2): an endpoint mapped ALONGSIDE the mesh services, through no seam this
|
||||||
|
// extension touches, must never come out anonymous. If it did, some future edit widened the
|
||||||
|
// AllowAnonymous convention beyond the five known prefixes — the exact failure class the
|
||||||
|
// fallback-policy opt-out exists to bound.
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
|
||||||
|
builder.Services.AddGrpc();
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapOtOpcUaMeshGrpcServices(
|
||||||
|
hasAdmin: true, hasDriver: true,
|
||||||
|
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056);
|
||||||
|
var sentinel = app.MapGet("/sentinel", () => "ok");
|
||||||
|
|
||||||
|
var sentinelEndpoint = ((IEndpointRouteBuilder)app).DataSources
|
||||||
|
.SelectMany(d => d.Endpoints)
|
||||||
|
.OfType<RouteEndpoint>()
|
||||||
|
.Single(e => e.RoutePattern.RawText == "/sentinel");
|
||||||
|
|
||||||
|
sentinelEndpoint.Metadata.GetMetadata<IAllowAnonymous>().ShouldBeNull();
|
||||||
|
_ = sentinel; // the builder handle itself is not the assertion target
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// harness
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static WebApplication BuildApp(
|
||||||
|
bool hasAdmin,
|
||||||
|
bool hasDriver,
|
||||||
|
int syncListenPort,
|
||||||
|
int configServeGrpcPort,
|
||||||
|
int telemetryListenPort)
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
|
||||||
|
builder.Services.AddGrpc();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.MapOtOpcUaMeshGrpcServices(
|
||||||
|
hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The real, callable RPC method endpoints — excludes grpc-dotnet's per-app "Unimplemented
|
||||||
|
/// service" catch-all and per-service "Unimplemented method for X" fallback (see class
|
||||||
|
/// remarks). Filtered against the exact set of known method route patterns rather than a
|
||||||
|
/// "starts with /" heuristic: inside the empty-prefix <c>MapGroup</c> the LocalDbSync map
|
||||||
|
/// (T3) uses, grpc-dotnet's own fallback patterns ALSO gain a leading <c>/</c>, so a
|
||||||
|
/// prefix-shaped filter would let them back in.
|
||||||
|
/// </summary>
|
||||||
|
private static List<RouteEndpoint> RealMethodEndpoints(WebApplication app) =>
|
||||||
|
((IEndpointRouteBuilder)app).DataSources
|
||||||
|
.SelectMany(d => d.Endpoints)
|
||||||
|
.OfType<RouteEndpoint>()
|
||||||
|
.Where(e => AllFivePrefixes.Contains(e.RoutePattern.RawText))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!;
|
||||||
|
|
||||||
|
/// <summary>Route patterns of the endpoints in <paramref name="endpoints"/> that carry <see cref="IAllowAnonymous"/>.</summary>
|
||||||
|
private static List<string> AnonymousPatternsOf(IEnumerable<RouteEndpoint> endpoints) =>
|
||||||
|
endpoints
|
||||||
|
.Where(e => e.Metadata.GetMetadata<IAllowAnonymous>() is not null)
|
||||||
|
.Select(RoutePatternOf)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Hosting;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.LocalDb.Replication;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
|
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.Protos.DeploymentArtifact.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-01 T4 — the CI-shaped guard that would have caught 03/S14: mesh gRPC calls through the
|
||||||
|
/// REAL <c>AddOtOpcUaAuth</c> + <c>UseAuthentication</c>/<c>UseAuthorization</c> stack, not a
|
||||||
|
/// hand-built pipeline that never installs the middleware that broke production.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The load-bearing assertion is the ALLOW.</b> A blocked call and a refused call both
|
||||||
|
/// "fail"; only a correctly-keyed call SUCCEEDING through the real cookie
|
||||||
|
/// <c>FallbackPolicy</c> proves the <c>.AllowAnonymous()</c> opt-outs (T2/T3) actually work in
|
||||||
|
/// a pipeline that carries the policy at all. Every positive test here failed before T2/T3 —
|
||||||
|
/// confirmed by running this class against the T1 commit, where every one of them was
|
||||||
|
/// challenged by the cookie handler instead of reaching the interceptor.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Negative control (anti-vacuity).</b> <see cref="LocalDbSyncService"/> is mapped directly
|
||||||
|
/// here — via a bare <c>MapGrpcService</c>, deliberately NOT through
|
||||||
|
/// <c>MapOtOpcUaMeshGrpcServices</c> — with no <c>.AllowAnonymous()</c>. A correctly-shaped
|
||||||
|
/// call to it must be refused by the cookie challenge. If it were not, the fixture's auth
|
||||||
|
/// stack would be inert and every positive assertion above would be proving nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MeshGrpcAuthStackTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string ConfigServeKey = "test-configserve-key";
|
||||||
|
private const string NodeCommandKey = "test-node-psk";
|
||||||
|
private const string CentralCommandKey = "test-central-psk";
|
||||||
|
private const string TelemetryKey = "test-telemetry-key";
|
||||||
|
private const string ClusterId = "MAIN";
|
||||||
|
|
||||||
|
private WebApplication _app = null!;
|
||||||
|
private ActorSystem _system = null!;
|
||||||
|
private int _port;
|
||||||
|
private TaskCompletionSource<object> _centralInbox = null!;
|
||||||
|
private TaskCompletionSource<object> _nodeInbox = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
builder.Environment.ApplicationName = typeof(MeshGrpcAuthStackTests).Assembly.GetName().Name!;
|
||||||
|
// Production, not the WebApplicationBuilder default: forces ValidateOnBuild/ValidateScopes OFF
|
||||||
|
// (they engage only under Development), so services never resolved without a login attempt
|
||||||
|
// (ILdapAuthService's graph) don't need to be fully stood up for this fixture to build.
|
||||||
|
builder.Environment.EnvironmentName = Environments.Production;
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
|
||||||
|
// DataProtection's PersistKeysToDbContext<OtOpcUaConfigDbContext> (inside AddOtOpcUaAuth)
|
||||||
|
// resolves a SCOPED OtOpcUaConfigDbContext via CreateScope() + GetRequiredService — it has no
|
||||||
|
// factory support. Mirrors AddOtOpcUaConfigDb's own factory+scoped-bridge shape, over InMemory.
|
||||||
|
var dbName = Guid.NewGuid().ToString("N");
|
||||||
|
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase(dbName));
|
||||||
|
builder.Services.AddScoped(sp =>
|
||||||
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
|
||||||
|
|
||||||
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
// The whole point of this fixture: exercise the REAL cookie handler, not
|
||||||
|
// AutoLoginAuthenticationHandler. See CookieSchemeIsTheRealHandler_NotAutoLogin.
|
||||||
|
["Security:Auth:DisableLogin"] = "false",
|
||||||
|
// This fixture serves cleartext h2c; a HTTPS-only cookie policy is irrelevant to what is
|
||||||
|
// under test (the FallbackPolicy applies identically either way) and would otherwise log
|
||||||
|
// a startup Warning for no reason.
|
||||||
|
["Security:Cookie:RequireHttpsCookie"] = "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
||||||
|
|
||||||
|
// Interceptor options bound directly (NOT via AddOtOpcUaCluster, which also wires
|
||||||
|
// AkkaClusterOptions/ConfigSourceOptions/TelemetryDialOptions validators with ValidateOnStart —
|
||||||
|
// heavy machinery this fixture has no use for and no seed-node config to satisfy).
|
||||||
|
builder.Services.Configure<ConfigServeOptions>(o => o.ApiKey = ConfigServeKey);
|
||||||
|
builder.Services.Configure<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
|
||||||
|
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
|
||||||
|
builder.Services.AddSingleton<IMeshPskProvider>(new FixedPskProvider(ClusterId, CentralCommandKey));
|
||||||
|
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
|
||||||
|
|
||||||
|
_system = ActorSystem.Create("mesh-auth-stack-" + Guid.NewGuid().ToString("N"));
|
||||||
|
_centralInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var registry = new ActorRegistry();
|
||||||
|
registry.Register<CentralCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_centralInbox)));
|
||||||
|
registry.Register<NodeCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_nodeInbox)));
|
||||||
|
builder.Services.AddSingleton(registry);
|
||||||
|
|
||||||
|
// The production interceptor set — the ONLY inbound auth on the gRPC side. Deliberately does
|
||||||
|
// NOT include LocalDbSyncAuthInterceptor: LocalDbSyncService is mapped below purely as the
|
||||||
|
// negative control and is never given a matching interceptor here.
|
||||||
|
builder.Services.AddGrpc(o =>
|
||||||
|
{
|
||||||
|
o.Interceptors.Add<ConfigServeAuthInterceptor>();
|
||||||
|
o.Interceptors.Add<CentralCommandAuthInterceptor>();
|
||||||
|
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
|
||||||
|
o.Interceptors.Add<MeshCommandAuthInterceptor>();
|
||||||
|
});
|
||||||
|
|
||||||
|
_port = GetFreePort();
|
||||||
|
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
|
||||||
|
|
||||||
|
_app = builder.Build();
|
||||||
|
_app.UseAuthentication();
|
||||||
|
_app.UseAuthorization();
|
||||||
|
|
||||||
|
// The production mapper — the four chainable maps carry AllowAnonymous (T2); LocalDbSync is
|
||||||
|
// NOT mapped here (syncListenPort: 0) so it stays free for the negative control below without
|
||||||
|
// a route collision.
|
||||||
|
_app.MapOtOpcUaMeshGrpcServices(
|
||||||
|
hasAdmin: true, hasDriver: true,
|
||||||
|
syncListenPort: 0, configServeGrpcPort: 1, telemetryListenPort: 1);
|
||||||
|
|
||||||
|
// Negative control (T4): mapped directly, NOT through MapOtOpcUaMeshGrpcServices, and
|
||||||
|
// deliberately with no .AllowAnonymous().
|
||||||
|
_app.MapGrpcService<LocalDbSyncService>();
|
||||||
|
|
||||||
|
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _app.StopAsync(TimeSpan.FromSeconds(10));
|
||||||
|
await _app.DisposeAsync();
|
||||||
|
await _system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CookieSchemeIsTheRealHandler_NotAutoLogin()
|
||||||
|
{
|
||||||
|
// If this resolves AutoLoginAuthenticationHandler, every assertion below is meaningless — the
|
||||||
|
// fallback policy would pass every request regardless of AllowAnonymous.
|
||||||
|
var provider = _app.Services.GetRequiredService<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider>();
|
||||||
|
var scheme = await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
scheme.ShouldNotBeNull();
|
||||||
|
scheme.HandlerType.ShouldBe(typeof(CookieAuthenticationHandler));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CorrectlyKeyed_ArtifactFetch_SucceedsThroughTheRealAuthStack()
|
||||||
|
{
|
||||||
|
var deploymentId = Guid.NewGuid();
|
||||||
|
var blob = "R3-01-T4-artifact-bytes"u8.ToArray();
|
||||||
|
var factory = _app.Services.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||||
|
await using (var db = await factory.CreateDbContextAsync(TestContext.Current.CancellationToken))
|
||||||
|
{
|
||||||
|
db.Deployments.Add(new Deployment
|
||||||
|
{
|
||||||
|
DeploymentId = deploymentId,
|
||||||
|
RevisionHash = new string('a', 64),
|
||||||
|
CreatedBy = "r3-01-t4",
|
||||||
|
ArtifactBlob = blob,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
|
||||||
|
var headers = new Metadata { { "authorization", $"Bearer {ConfigServeKey}" } };
|
||||||
|
|
||||||
|
using var call = client.Fetch(
|
||||||
|
new FetchRequest { DeploymentId = deploymentId.ToString() },
|
||||||
|
headers,
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await foreach (var chunk in call.ResponseStream.ReadAllAsync(TestContext.Current.CancellationToken))
|
||||||
|
chunk.Data.WriteTo(buffer);
|
||||||
|
|
||||||
|
buffer.ToArray().ShouldBe(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CorrectlyKeyed_CentralCommandReportApply_SucceedsThroughTheRealAuthStack()
|
||||||
|
{
|
||||||
|
var ack = new ApplyAck(
|
||||||
|
new DeploymentId(Guid.NewGuid()),
|
||||||
|
new NodeId("node-a"),
|
||||||
|
ApplyAckOutcome.Applied,
|
||||||
|
FailureReason: null,
|
||||||
|
CorrelationId.NewId());
|
||||||
|
|
||||||
|
var client = new CentralCommandService.CentralCommandServiceClient(Channel());
|
||||||
|
var headers = new Metadata
|
||||||
|
{
|
||||||
|
{ "authorization", $"Bearer {CentralCommandKey}" },
|
||||||
|
{ "x-otopcua-cluster", ClusterId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.ReportApplyAsync(
|
||||||
|
MeshTransportDtoMapper.ToProto(ack), headers, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var received = await _centralInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||||
|
received.ShouldBeOfType<ApplyAck>().DeploymentId.ShouldBe(ack.DeploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CorrectlyKeyed_NodeCommandExecute_SucceedsThroughTheRealAuthStack()
|
||||||
|
{
|
||||||
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
||||||
|
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
|
||||||
|
var headers = new Metadata { { "authorization", $"Bearer {NodeCommandKey}" } };
|
||||||
|
|
||||||
|
await client.ExecuteAsync(
|
||||||
|
MeshTransportDtoMapper.ToEnvelope(command), headers, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var received = await _nodeInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||||
|
received.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FalsifiabilityControl_AnUnAnonymisedServiceMapped_RefusesEvenACorrectlyShapedCall()
|
||||||
|
{
|
||||||
|
// LocalDbSyncService carries no AllowAnonymous in this fixture (see InitializeAsync). If this
|
||||||
|
// call succeeds, the fixture's auth stack is not actually challenging unmarked endpoints and
|
||||||
|
// every "succeeds through the real auth stack" assertion above proves nothing.
|
||||||
|
var client = new ZB.MOM.WW.LocalDb.Contracts.LocalDbSync.LocalDbSyncClient(Channel());
|
||||||
|
var headers = new Metadata { { "authorization", "Bearer irrelevant-to-this-endpoint" } };
|
||||||
|
|
||||||
|
using var call = client.Sync(headers, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
await call.RequestStream.CompleteAsync();
|
||||||
|
|
||||||
|
// Review (R3-01): asserting a bare Exception was vacuous — an anonymised endpoint ALSO throws
|
||||||
|
// here (handler deps are not wired in this fixture => RpcException Unknown). Only the
|
||||||
|
// cookie-challenge signature discriminates: the FallbackPolicy's 302 to /login is a
|
||||||
|
// non-gRPC response, surfaced by grpc-dotnet as Internal + PROTOCOL_ERROR.
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(
|
||||||
|
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.Internal);
|
||||||
|
ex.Status.Detail.ShouldContain("PROTOCOL_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// T5 - status fidelity: a no-PSK / wrong-PSK call reports the INTERCEPTOR's verdict, never a
|
||||||
|
// cookie challenge. This is the vacuity trap the plan calls out: ConfigServeAuthInterceptor
|
||||||
|
// throws StatusCode.Unauthenticated, and Grpc.Net.Client also maps an HTTP 401 to
|
||||||
|
// Unauthenticated - so "the call failed with Unauthenticated" is true on a broken build AND a
|
||||||
|
// fixed one. The interceptor's DETAIL string is the only thing that discriminates them.
|
||||||
|
//
|
||||||
|
// Observed cookie-challenge signature (captured red-then-green against this same fixture, see
|
||||||
|
// MeshGrpcAuthStackTests' positive tests): the challenge does not surface as a clean
|
||||||
|
// Unauthenticated/PermissionDenied at all. Grpc.Net.Client reports
|
||||||
|
// RpcException(StatusCode.Internal, "Error starting gRPC call. HttpRequestException: The HTTP/2
|
||||||
|
// server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1)..."), because the cookie
|
||||||
|
// handler's redirect/401 breaks the prior-knowledge h2c stream before any gRPC trailers are
|
||||||
|
// written. So a status-only assertion here would ALSO be vacuous in the other direction: a
|
||||||
|
// regressed build fails with StatusCode.Internal, never Unauthenticated/PermissionDenied, and a
|
||||||
|
// caller checking only "did it throw" cannot tell a blocked call from a refused one. Recorded
|
||||||
|
// here per the plan (T5 step 2) - this is the exact string the live gate (T10) greps for.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("Bearer the-wrong-key")]
|
||||||
|
public async Task NoOrWrongPsk_ArtifactFetch_FailsWithTheInterceptorsDetail(string? authHeader)
|
||||||
|
{
|
||||||
|
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
|
||||||
|
var headers = new Metadata();
|
||||||
|
if (authHeader is not null)
|
||||||
|
headers.Add("authorization", authHeader);
|
||||||
|
|
||||||
|
using var call = client.Fetch(
|
||||||
|
new FetchRequest { DeploymentId = Guid.NewGuid().ToString() },
|
||||||
|
headers,
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(
|
||||||
|
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.Unauthenticated);
|
||||||
|
ex.Status.Detail.ShouldBe("Config-serve authentication failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("Bearer the-wrong-key")]
|
||||||
|
public async Task NoOrWrongPsk_TelemetrySubscribe_FailsWithTheInterceptorsDetail(string? authHeader)
|
||||||
|
{
|
||||||
|
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
|
||||||
|
var headers = new Metadata();
|
||||||
|
if (authHeader is not null)
|
||||||
|
headers.Add("authorization", authHeader);
|
||||||
|
|
||||||
|
using var call = client.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = "r3-01-t5" },
|
||||||
|
headers,
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(
|
||||||
|
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||||
|
ex.Status.Detail.ShouldBe("Telemetry stream authentication failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("Bearer the-wrong-key")]
|
||||||
|
public async Task NoOrWrongPsk_NodeCommandExecute_FailsWithTheInterceptorsDetail(string? authHeader)
|
||||||
|
{
|
||||||
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
||||||
|
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
|
||||||
|
var headers = new Metadata();
|
||||||
|
if (authHeader is not null)
|
||||||
|
headers.Add("authorization", authHeader);
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(() => client.ExecuteAsync(
|
||||||
|
MeshTransportDtoMapper.ToEnvelope(command), headers,
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken).ResponseAsync);
|
||||||
|
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||||
|
ex.Status.Detail.ShouldBe("Node command authentication failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// harness
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private GrpcChannel Channel() => GrpcChannel.ForAddress($"http://localhost:{_port}");
|
||||||
|
|
||||||
|
private static int GetFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands out the one cluster key <see cref="CentralCommandAuthInterceptor"/> expects. Stands
|
||||||
|
/// in for the real <c>MeshPskProvider</c> (secret-store lookups, caching, the known-cluster
|
||||||
|
/// gate) — out of scope here, where the subject is the HTTP-layer auth stack, not the PSK
|
||||||
|
/// resolution chain (that is <c>MeshPskProviderTests</c>' job).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FixedPskProvider(string clusterId, string psk) : IMeshPskProvider
|
||||||
|
{
|
||||||
|
public bool TryGetPsk(string clusterId2, [NotNullWhen(true)] out string? psk2)
|
||||||
|
{
|
||||||
|
psk2 = string.Equals(clusterId2, clusterId, StringComparison.Ordinal) ? psk : null;
|
||||||
|
return psk2 is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Matches(string clusterId2, string presented) =>
|
||||||
|
string.Equals(clusterId2, clusterId, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(presented, psk, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds) { }
|
||||||
|
|
||||||
|
public void Invalidate(string clusterId2) { }
|
||||||
|
|
||||||
|
public void InvalidateAll() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Completes an inbox with the first message it receives.</summary>
|
||||||
|
private sealed class CaptureActor : ReceiveActor
|
||||||
|
{
|
||||||
|
public static Props Props(TaskCompletionSource<object> sink) =>
|
||||||
|
Akka.Actor.Props.Create(() => new CaptureActor(sink));
|
||||||
|
|
||||||
|
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Hosting;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-01 T6 — pins the report's "driver-only nodes are structurally unaffected" finding
|
||||||
|
/// (archreview 03/S14): a node with no <c>admin</c> role never registers
|
||||||
|
/// <c>AddOtOpcUaAuth</c>/<c>UseAuthentication</c>/<c>UseAuthorization</c> at all, so it has no
|
||||||
|
/// cookie <c>FallbackPolicy</c> to opt out of in the first place — the PSK interceptors were
|
||||||
|
/// always the ONLY gate on this shape, with or without <c>.AllowAnonymous()</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A future "register auth everywhere for consistency" change would silently reintroduce 03/S14
|
||||||
|
/// for every site node in the fleet. This class is what turns that into a failing test instead of
|
||||||
|
/// a live outage: it asserts BOTH that the driver-only mesh endpoints behave identically to the
|
||||||
|
/// fused shape's (T4) AND that the reason they do — no <see cref="IAuthorizationPolicyProvider"/>
|
||||||
|
/// registered at all — actually holds, so a change that adds auth services here fails on the
|
||||||
|
/// second assertion even if it somehow left the first passing.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MeshGrpcDriverOnlyPipelineTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string NodeCommandKey = "test-driveronly-node-psk";
|
||||||
|
private const string TelemetryKey = "test-driveronly-telemetry-key";
|
||||||
|
|
||||||
|
private WebApplication _app = null!;
|
||||||
|
private ActorSystem _system = null!;
|
||||||
|
private int _port;
|
||||||
|
private ITelemetryLocalHub _hub = null!;
|
||||||
|
private TaskCompletionSource<object> _nodeInbox = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
builder.Environment.ApplicationName = typeof(MeshGrpcDriverOnlyPipelineTests).Assembly.GetName().Name!;
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
|
||||||
|
// Deliberately NO AddOtOpcUaAuth, NO UseAuthentication, NO UseAuthorization — this IS the
|
||||||
|
// driver-only shape (Program.cs gates all three on hasAdmin). Nothing here should need them.
|
||||||
|
builder.Services.Configure<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
|
||||||
|
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
|
||||||
|
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
|
||||||
|
|
||||||
|
_system = ActorSystem.Create("mesh-driveronly-" + Guid.NewGuid().ToString("N"));
|
||||||
|
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var registry = new ActorRegistry();
|
||||||
|
registry.Register<NodeCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_nodeInbox)));
|
||||||
|
builder.Services.AddSingleton(registry);
|
||||||
|
|
||||||
|
// The driver-role interceptor subset only (matches Program.cs's `if (hasDriver)` branch);
|
||||||
|
// no ConfigServeAuthInterceptor/CentralCommandAuthInterceptor — those are admin-role.
|
||||||
|
builder.Services.AddGrpc(o =>
|
||||||
|
{
|
||||||
|
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
|
||||||
|
o.Interceptors.Add<MeshCommandAuthInterceptor>();
|
||||||
|
});
|
||||||
|
|
||||||
|
_port = GetFreePort();
|
||||||
|
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
|
||||||
|
|
||||||
|
_app = builder.Build();
|
||||||
|
_hub = _app.Services.GetRequiredService<ITelemetryLocalHub>();
|
||||||
|
|
||||||
|
// hasAdmin: false — the driver-only shape. syncListenPort: 0 so LocalDbSync (admin-agnostic
|
||||||
|
// but out of scope here) is not mapped; configServeGrpcPort: 0 because a driver-only node
|
||||||
|
// never carries ConfigServe.
|
||||||
|
_app.MapOtOpcUaMeshGrpcServices(
|
||||||
|
hasAdmin: false, hasDriver: true,
|
||||||
|
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 1);
|
||||||
|
|
||||||
|
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _app.StopAsync(TimeSpan.FromSeconds(10));
|
||||||
|
await _app.DisposeAsync();
|
||||||
|
await _system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoAuthorizationPolicyProviderIsRegistered()
|
||||||
|
{
|
||||||
|
// THE structural reason this shape is unaffected: with AddOtOpcUaAuth never called, there is
|
||||||
|
// no FallbackPolicy to opt out of. If a future change registers authorization services on a
|
||||||
|
// driver-only node, this goes red before any endpoint's behaviour does.
|
||||||
|
_app.Services.GetService<IAuthorizationPolicyProvider>().ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CorrectlyKeyed_NodeCommandExecute_Succeeds()
|
||||||
|
{
|
||||||
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
||||||
|
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
|
||||||
|
var headers = new Metadata { { "authorization", $"Bearer {NodeCommandKey}" } };
|
||||||
|
|
||||||
|
await client.ExecuteAsync(
|
||||||
|
MeshTransportDtoMapper.ToEnvelope(command), headers,
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var received = await _nodeInbox.Task.WaitAsync(
|
||||||
|
TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||||
|
received.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoKey_NodeCommandExecute_FailsWithTheInterceptorsDetail()
|
||||||
|
{
|
||||||
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
||||||
|
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(() => client.ExecuteAsync(
|
||||||
|
MeshTransportDtoMapper.ToEnvelope(command), new Metadata(),
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken).ResponseAsync);
|
||||||
|
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||||
|
ex.Status.Detail.ShouldBe("Node command authentication failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CorrectlyKeyed_TelemetrySubscribe_Succeeds()
|
||||||
|
{
|
||||||
|
// Health is snapshot-style (ITelemetryLocalHub.Emit / class remarks): emitting BEFORE the
|
||||||
|
// Subscribe call means the new subscriber's reader is primed with it immediately, with no
|
||||||
|
// race against a live delta.
|
||||||
|
_hub.Emit(new TelemetryItem.Health(new DriverHealthChanged(
|
||||||
|
"C1", "driver-1", "Healthy", DateTime.UtcNow, null, 0, DateTime.UtcNow)));
|
||||||
|
|
||||||
|
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
|
||||||
|
var headers = new Metadata { { "authorization", $"Bearer {TelemetryKey}" } };
|
||||||
|
|
||||||
|
using var call = client.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = "r3-01-t6" },
|
||||||
|
headers,
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
(await call.ResponseStream.MoveNext(TestContext.Current.CancellationToken)).ShouldBeTrue();
|
||||||
|
call.ResponseStream.Current.DriverHealth.DriverInstanceId.ShouldBe("driver-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoKey_TelemetrySubscribe_FailsWithTheInterceptorsDetail()
|
||||||
|
{
|
||||||
|
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
|
||||||
|
|
||||||
|
using var call = client.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = "r3-01-t6-nokey" },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(
|
||||||
|
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
||||||
|
ex.Status.Detail.ShouldBe("Telemetry stream authentication failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// harness
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private GrpcChannel Channel() => GrpcChannel.ForAddress($"http://localhost:{_port}");
|
||||||
|
|
||||||
|
private static int GetFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Completes an inbox with the first message it receives.</summary>
|
||||||
|
private sealed class CaptureActor : ReceiveActor
|
||||||
|
{
|
||||||
|
public static Props Props(TaskCompletionSource<object> sink) =>
|
||||||
|
Akka.Actor.Props.Create(() => new CaptureActor(sink));
|
||||||
|
|
||||||
|
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-01 T7 — a source-scan guard, following the solution-root-walk idiom
|
||||||
|
/// <c>ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.FlushLoggingConventionTests</c> uses. Complements the
|
||||||
|
/// endpoint-metadata census (<c>MeshGrpcAnonymousMetadataTests</c>): the census catches a map
|
||||||
|
/// added to <c>MeshGrpcEndpoints</c> without the <c>AllowAnonymous</c> convention, but it cannot
|
||||||
|
/// catch a map added to <c>Program.cs</c> that bypasses the helper entirely — that shape never
|
||||||
|
/// goes through <c>MapOtOpcUaMeshGrpcServices</c> at all, so the census would simply never see it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Would have FAILED at HEAD before T1: <c>Program.cs</c> used to carry the five map sites
|
||||||
|
/// inline (<c>MapZbLocalDbSync()</c>, four <c>MapGrpcService<T>()</c> calls) with no
|
||||||
|
/// <c>MapOtOpcUaMeshGrpcServices</c> call at all.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MeshGrpcMapConventionTests
|
||||||
|
{
|
||||||
|
private static readonly string ProgramCsPath = LocateProgramCs();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProgramCs_ContainsNoDirectMeshGrpcMapSites_AndExactlyOneMapperCall()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(ProgramCsPath);
|
||||||
|
|
||||||
|
// Guard-of-the-guard: a bad path (an empty file, or one that never had the mapper in the
|
||||||
|
// first place) must not pass this test by finding nothing to count.
|
||||||
|
source.ShouldNotBeNullOrWhiteSpace();
|
||||||
|
source.ShouldContain("MapOtOpcUaMeshGrpcServices(");
|
||||||
|
|
||||||
|
CountOccurrences(source, "MapGrpcService<").ShouldBe(0,
|
||||||
|
"every MapGrpcService<T>() call must live inside MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices, " +
|
||||||
|
"not inline in Program.cs (03/S14 — a map added outside the helper is invisible to the " +
|
||||||
|
"endpoint-metadata census).");
|
||||||
|
CountOccurrences(source, "MapZbLocalDbSync(").ShouldBe(0,
|
||||||
|
"the LocalDb sync map must live inside MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices, not inline " +
|
||||||
|
"in Program.cs, for the same reason as MapGrpcService<T>() above.");
|
||||||
|
CountOccurrences(source, "MapOtOpcUaMeshGrpcServices(").ShouldBe(1,
|
||||||
|
"Program.cs must call the mapper exactly once; more than one call would map the mesh gRPC " +
|
||||||
|
"services twice (a route conflict) and zero calls would drop them entirely.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountOccurrences(string source, string needle)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
var index = 0;
|
||||||
|
while ((index = source.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
index += needle.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateProgramCs()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
dir.ShouldNotBeNull("Could not find solution root (ZB.MOM.WW.OtOpcUa.slnx).");
|
||||||
|
return Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host", "Program.cs");
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -236,8 +236,10 @@ public sealed class MeshRegistrationRoleScopingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pins the scanner's expected result shape against today's Program.cs — 6 markers,
|
/// Pins the scanner's expected result shape against today's Program.cs — 5 markers,
|
||||||
/// 5 braced blocks + the 1 braceless ConfigDb gate, so 6 scanned ranges. Without this, a
|
/// 4 braced blocks + the 1 braceless ConfigDb gate, so 5 scanned ranges (the data-plane
|
||||||
|
/// key-store branch moved into <c>DataPlaneAuthRegistration</c> for arch-review 03/S18, taking
|
||||||
|
/// one braced marker with it). Without this, a
|
||||||
/// scanner bug that silently drops or merges a marker (as the pre-fix version did to the
|
/// scanner bug that silently drops or merges a marker (as the pre-fix version did to the
|
||||||
/// braceless marker — it fabricated a phantom range spanning an unrelated if-block and swallowed
|
/// braceless marker — it fabricated a phantom range spanning an unrelated if-block and swallowed
|
||||||
/// a real nested 'if (hasAdmin)' block inside it, yielding 5 ranges instead of 6) would not
|
/// a real nested 'if (hasAdmin)' block inside it, yielding 5 ranges instead of 6) would not
|
||||||
@@ -248,13 +250,13 @@ public sealed class MeshRegistrationRoleScopingTests
|
|||||||
{
|
{
|
||||||
var source = ReadProgramSource();
|
var source = ReadProgramSource();
|
||||||
|
|
||||||
CountOccurrences(source, HasAdminMarker).ShouldBe(6,
|
CountOccurrences(source, HasAdminMarker).ShouldBe(5,
|
||||||
"Program.cs's 'if (hasAdmin)' marker count changed. If a marker was added or removed, " +
|
"Program.cs's 'if (hasAdmin)' marker count changed. If a marker was added or removed, " +
|
||||||
"update this pin — and check whether the new/removed occurrence is braced or braceless — " +
|
"update this pin — and check whether the new/removed occurrence is braced or braceless — " +
|
||||||
"rather than letting the scanner silently mis-shape its result.");
|
"rather than letting the scanner silently mis-shape its result.");
|
||||||
|
|
||||||
var ranges = FindHasAdminBlockRanges(source);
|
var ranges = FindHasAdminBlockRanges(source);
|
||||||
ranges.Count.ShouldBe(6,
|
ranges.Count.ShouldBe(5,
|
||||||
"expected one scanned range per 'if (hasAdmin)' marker, including the braceless ConfigDb " +
|
"expected one scanned range per 'if (hasAdmin)' marker, including the braceless ConfigDb " +
|
||||||
"gate. A mismatch means the scanner adopted an unrelated brace or otherwise merged/dropped " +
|
"gate. A mismatch means the scanner adopted an unrelated brace or otherwise merged/dropped " +
|
||||||
"a marker instead of handling each one independently.");
|
"a marker instead of handling each one independently.");
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="AclRevalidationTimer"/> — the low-frequency trigger that makes key expiry reach
|
||||||
|
/// a live subscription without waiting for a deployment (arch-review 03/S19).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AclRevalidationTimerTests
|
||||||
|
{
|
||||||
|
/// <summary>A positive interval arms one timer whose due time and period are both that interval.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_positive_interval_creates_a_timer_with_that_period()
|
||||||
|
{
|
||||||
|
var clock = new RecordingTimeProvider();
|
||||||
|
|
||||||
|
using var _ = new AclRevalidationTimer(
|
||||||
|
() => { }, TimeSpan.FromSeconds(30), clock, NullLogger.Instance);
|
||||||
|
|
||||||
|
clock.Created.Count.ShouldBe(1);
|
||||||
|
clock.Created[0].DueTime.ShouldBe(TimeSpan.FromSeconds(30));
|
||||||
|
clock.Created[0].Period.ShouldBe(TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A non-positive interval arms nothing at all, and says so once — a disabled guard should be
|
||||||
|
/// visible in the log rather than inferred from an absence of activity.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_zero_interval_creates_no_timer()
|
||||||
|
{
|
||||||
|
var clock = new RecordingTimeProvider();
|
||||||
|
var log = new CapturingLogger();
|
||||||
|
|
||||||
|
using var _ = new AclRevalidationTimer(() => { }, TimeSpan.Zero, clock, log);
|
||||||
|
|
||||||
|
clock.Created.ShouldBeEmpty();
|
||||||
|
log.Lines.ShouldContain(l => l.Contains("RevalidationIntervalSeconds", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pass that throws must not escape the tick.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An unhandled exception on a timer callback thread takes the process down. The next tick is the
|
||||||
|
/// recovery, so the fault is logged and the timer keeps running.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void A_throwing_pass_does_not_escape_the_tick()
|
||||||
|
{
|
||||||
|
var log = new CapturingLogger();
|
||||||
|
using var timer = new AclRevalidationTimer(
|
||||||
|
() => throw new InvalidOperationException("pass blew up"),
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
new RecordingTimeProvider(),
|
||||||
|
log);
|
||||||
|
|
||||||
|
Should.NotThrow(timer.Tick);
|
||||||
|
log.Lines.ShouldContain(l => l.Contains("revalidation", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tick runs the pass — the control for the two "nothing happened" cases above.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_tick_runs_the_pass()
|
||||||
|
{
|
||||||
|
var runs = 0;
|
||||||
|
using var timer = new AclRevalidationTimer(
|
||||||
|
() => runs++, TimeSpan.FromSeconds(30), new RecordingTimeProvider(), NullLogger.Instance);
|
||||||
|
|
||||||
|
timer.Tick();
|
||||||
|
timer.Tick();
|
||||||
|
|
||||||
|
runs.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposing disposes the underlying timer and stops further ticks.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_stops_further_ticks()
|
||||||
|
{
|
||||||
|
var clock = new RecordingTimeProvider();
|
||||||
|
var runs = 0;
|
||||||
|
var timer = new AclRevalidationTimer(
|
||||||
|
() => runs++, TimeSpan.FromSeconds(30), clock, NullLogger.Instance);
|
||||||
|
|
||||||
|
timer.Dispose();
|
||||||
|
|
||||||
|
clock.Created[0].Disposed.ShouldBeTrue();
|
||||||
|
timer.Tick();
|
||||||
|
runs.ShouldBe(0, "a disposed timer must not run the pass again");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="TimeProvider"/> that records the timers asked of it instead of arming real ones.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RecordingTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
/// <summary>Every timer this provider was asked to create, in order.</summary>
|
||||||
|
public List<FakeTimer> Created { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||||
|
{
|
||||||
|
var timer = new FakeTimer(dueTime, period);
|
||||||
|
Created.Add(timer);
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A timer that does nothing but remember how it was asked for, and whether it was disposed.</summary>
|
||||||
|
internal sealed class FakeTimer(TimeSpan dueTime, TimeSpan period) : ITimer
|
||||||
|
{
|
||||||
|
public TimeSpan DueTime { get; } = dueTime;
|
||||||
|
|
||||||
|
public TimeSpan Period { get; } = period;
|
||||||
|
|
||||||
|
public bool Disposed { get; private set; }
|
||||||
|
|
||||||
|
public bool Change(TimeSpan due, TimeSpan p) => true;
|
||||||
|
|
||||||
|
public void Dispose() => Disposed = true;
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Disposed = true;
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collects rendered log messages so a "said so once" claim can be asserted.</summary>
|
||||||
|
private sealed class CapturingLogger : ILogger
|
||||||
|
{
|
||||||
|
public List<string> Lines { get; } = [];
|
||||||
|
|
||||||
|
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) => Lines.Add(formatter(state, exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the role-conditional data-plane authentication wiring (arch-review 03/S18). A decorator
|
||||||
|
/// registered in one branch of a role conditional is the "built but never wired" shape, so the branch
|
||||||
|
/// each node role lands in is asserted rather than assumed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiKeyStoreRegistrationTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The authorizer's clock and the registered clock must be the SAME instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="DataPlaneAuthorizer"/> already documents that a divergence between two clocks denies
|
||||||
|
/// every request with a verdict indistinguishable from "no grant matched" — found once by a test
|
||||||
|
/// clock two hours ahead. One registered singleton threaded into every consumer makes the
|
||||||
|
/// divergence unrepresentable instead of merely unlikely, so the registration is what is pinned.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void The_authorizer_and_the_key_store_share_one_TimeProvider()
|
||||||
|
{
|
||||||
|
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
|
||||||
|
|
||||||
|
var registered = sp.GetRequiredService<TimeProvider>();
|
||||||
|
var authorizer = sp.GetRequiredService<DataPlaneAuthorizer>();
|
||||||
|
|
||||||
|
registered.ShouldBeSameAs(Probe.Instance);
|
||||||
|
authorizer.Clock.ShouldBeSameAs(registered);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An admin (or fused admin+driver) node's key store is the <see cref="BoundedApiKeyStore"/>
|
||||||
|
/// decorator, wrapping the ConfigDb store.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The decorator lives in ONE branch of a role conditional, which is the "built but never wired"
|
||||||
|
/// shape OVERALL theme 3 names. Unwired, every unit test in <c>BoundedApiKeyStoreTests</c> still
|
||||||
|
/// passes and the deadline protects nothing.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void An_admin_node_wraps_the_ConfigDb_store_in_the_bound()
|
||||||
|
{
|
||||||
|
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
|
||||||
|
|
||||||
|
var store = sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<BoundedApiKeyStore>();
|
||||||
|
store.Inner.ShouldBeOfType<ConfigDbApiKeyStore>();
|
||||||
|
store.Clock.ShouldBeSameAs(sp.GetRequiredService<TimeProvider>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A driver-only node's key store is the bare artifact store — deliberately NOT wrapped.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// It is two dictionary probes on an in-memory snapshot, so it can neither hang nor be worth
|
||||||
|
/// caching; wrapping it would add a deadline to a call that cannot miss one and a second copy of
|
||||||
|
/// data the snapshot already holds.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void A_driver_only_node_gets_the_bare_artifact_store()
|
||||||
|
{
|
||||||
|
using var sp = BuildNode(hasAdmin: false, hasDriver: true);
|
||||||
|
|
||||||
|
sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<ArtifactApiKeyStore>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A node carrying neither role registers the deny-all authenticator <b>explicitly</b>, and no key
|
||||||
|
/// store at all.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Explicitly, not by absence: <c>OtOpcUaServerHostedService</c> takes
|
||||||
|
/// <see cref="IOpcUaUserAuthenticator"/> as a required constructor parameter, and the built-in
|
||||||
|
/// container does not fall back to a default for an unregistered service — it throws, and the node
|
||||||
|
/// does not start. The assertion is on the singleton INSTANCE, so a future registration that
|
||||||
|
/// happened to resolve some other deny-all type still surfaces here.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void A_node_with_neither_role_registers_the_deny_all_authenticator()
|
||||||
|
{
|
||||||
|
using var sp = BuildNode(hasAdmin: false, hasDriver: false);
|
||||||
|
|
||||||
|
sp.GetRequiredService<IOpcUaUserAuthenticator>().ShouldBeSameAs(NullOpcUaUserAuthenticator.Instance);
|
||||||
|
sp.GetService<IApiKeyStore>().ShouldBeNull("a node that serves no data plane has no keys to read");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the data-plane authentication wiring for one node role combination.</summary>
|
||||||
|
/// <param name="hasAdmin">Whether the node carries the <c>admin</c> role.</param>
|
||||||
|
/// <param name="hasDriver">Whether the node carries the <c>driver</c> role.</param>
|
||||||
|
/// <returns>The built provider; the caller disposes it.</returns>
|
||||||
|
internal static ServiceProvider BuildNode(bool hasAdmin, bool hasDriver)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance));
|
||||||
|
services.AddSingleton<TimeProvider>(Probe.Instance);
|
||||||
|
services.Configure<ApiKeyOptions>(_ => { });
|
||||||
|
services.Configure<AclOptions>(_ => { });
|
||||||
|
services.Configure<AkkaClusterOptions>(o => o.Roles = ["admin", "driver", "cluster-MAIN"]);
|
||||||
|
services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
|
||||||
|
services.AddSingleton<PermissionTrieCache>();
|
||||||
|
services.AddSingleton<DataPlaneAuthorityState>();
|
||||||
|
|
||||||
|
if (hasAdmin)
|
||||||
|
{
|
||||||
|
services.AddDbContextFactory<OtOpcUaConfigDbContext>(
|
||||||
|
o => o.UseInMemoryDatabase($"apikey-reg-{Guid.NewGuid():N}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A clock distinguishable from <see cref="TimeProvider.System"/> by reference.</summary>
|
||||||
|
private sealed class Probe : TimeProvider
|
||||||
|
{
|
||||||
|
public static readonly Probe Instance = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override DateTimeOffset GetUtcNow() => new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="BoundedApiKeyStore"/> — the deadline and TTL cache the OPC UA impersonation
|
||||||
|
/// callback's key lookup runs under (arch-review 03/S18).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The deadline cases are driven through the <b>real</b> <see cref="ApiKeyOpcUaUserAuthenticator"/>
|
||||||
|
/// rather than the store directly, because the fail-closed conversion — a throw becoming the
|
||||||
|
/// standard opaque denial — is part of what is being asserted, and it lives in the authenticator's
|
||||||
|
/// catch-all rather than in the decorator.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Deadline cases use a REAL short timeout; TTL cases use the fake clock.</b> The hand-rolled
|
||||||
|
/// <see cref="FakeClock"/> overrides <c>GetUtcNow()</c> only, so <c>CreateTimer</c> falls through to
|
||||||
|
/// the base implementation and produces a real system timer — advancing the fake clock does not
|
||||||
|
/// fire a <c>CancelAfter</c>. A deadline test "improved" to advance the fake clock would pass for
|
||||||
|
/// the wrong reason: the inner task simply never completing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class BoundedApiKeyStoreTests
|
||||||
|
{
|
||||||
|
private const string Pepper = "unit-test-pepper";
|
||||||
|
private const string Principal = "scadabridge-dcl";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A store that never answers must not hold the activation open: the deadline fires and the
|
||||||
|
/// activation is denied.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The SDK raises impersonation under a <c>SessionManager</c>-wide lock, so a lookup that does not
|
||||||
|
/// return stalls every session activation in the server — not just this one. That is the whole of
|
||||||
|
/// 03/S18, and it is why the bound is a deadline rather than a cache.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_blocked_store_denies_without_hanging_the_activation()
|
||||||
|
{
|
||||||
|
var (presented, inner) = SetUp();
|
||||||
|
inner.BlockForever = true;
|
||||||
|
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
|
||||||
|
|
||||||
|
var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None)
|
||||||
|
.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The positive control for <see cref="A_blocked_store_denies_without_hanging_the_activation"/>:
|
||||||
|
/// the same fake, answering normally, still authenticates.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Without it a build whose decorator denied <b>everything</b> would pass every deadline case in
|
||||||
|
/// this class, and the bound would look correct while having locked the fleet out.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reachable_store_still_authenticates()
|
||||||
|
{
|
||||||
|
var (presented, inner) = SetUp();
|
||||||
|
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
|
||||||
|
|
||||||
|
var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue();
|
||||||
|
result.Principal.ShouldBe(Principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A lookup that completes with a perfectly valid key <b>after</b> the deadline still denies —
|
||||||
|
/// nothing races back in past the bound.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_deadline_never_admits()
|
||||||
|
{
|
||||||
|
var (presented, inner) = SetUp();
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
inner.Gate = gate.Task;
|
||||||
|
var auth = Auth(Bounded(inner, timeoutMs: 100, cacheSeconds: 0));
|
||||||
|
|
||||||
|
var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
|
||||||
|
|
||||||
|
// Let the deadline pass, THEN hand the inner store its (valid) answer.
|
||||||
|
await Task.Delay(400);
|
||||||
|
gate.SetResult();
|
||||||
|
|
||||||
|
var result = await pending.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A non-positive <c>StoreTimeoutMs</c> disables the deadline, restoring the unbounded behaviour
|
||||||
|
/// byte-for-byte — the documented rollback.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_zero_timeout_disables_the_deadline()
|
||||||
|
{
|
||||||
|
var (presented, inner) = SetUp();
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
inner.Gate = gate.Task;
|
||||||
|
var auth = Auth(Bounded(inner, timeoutMs: 0, cacheSeconds: 0));
|
||||||
|
|
||||||
|
var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
|
||||||
|
|
||||||
|
// Far longer than any deadline this class configures; an active bound would have denied by now.
|
||||||
|
await Task.Delay(300);
|
||||||
|
pending.IsCompleted.ShouldBeFalse("a disabled deadline must not cancel a slow lookup");
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
(await pending.WaitAsync(TimeSpan.FromSeconds(5))).Success.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The anonymous activation path gets the same deadline, and the same denial.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// It runs on every unauthenticated connect, so leaving it unbounded would leave the
|
||||||
|
/// server-wide-stall hazard fully open on the busiest activation path.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Anonymous_grant_resolution_is_bounded_too()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
inner.BlockForever = true;
|
||||||
|
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
|
||||||
|
|
||||||
|
var result = await auth.AuthenticateAnonymousAsync(CancellationToken.None)
|
||||||
|
.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- TTL read-through cache ----------
|
||||||
|
|
||||||
|
/// <summary>A second activation inside the TTL is served from the cache and never reaches the store.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_second_activation_within_the_TTL_does_not_reach_the_store()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||||
|
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(2));
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||||
|
|
||||||
|
inner.Calls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FALSIFICATION CONTROL. A key revoked in the store is denied once the TTL has passed — the
|
||||||
|
/// cache expires rather than merely existing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A cache with no expiry at all passes every other case in this class: the hit test, the
|
||||||
|
/// read-through test and the fault test are all satisfied by "store once, serve forever". This is
|
||||||
|
/// the one that makes <c>StoreCacheSeconds</c> a bound rather than a claim.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_revoked_key_is_denied_once_the_TTL_has_passed()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||||
|
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default))
|
||||||
|
.ShouldNotBeNull("non-vacuity: the key really was usable before the revocation");
|
||||||
|
|
||||||
|
inner.Revoked = true;
|
||||||
|
|
||||||
|
// Still inside the TTL — the documented, accepted staleness window.
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(3));
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Past it — the store is re-read and the revocation lands.
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(3));
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
||||||
|
inner.Calls.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A non-positive TTL disables the cache: every call reads through, today's behaviour.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task TTL_zero_reads_through_every_time()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 0, clock);
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
|
||||||
|
|
||||||
|
inner.Calls.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A fault is never cached: the next call reaches the store, so an outage cannot outlive itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Caching a deny would be fail-closed and is still wrong — it would go on denying valid keys for
|
||||||
|
/// a further TTL after the database came back.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_faulted_store_is_not_cached()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock);
|
||||||
|
inner.ThrowsRemaining = 1;
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default));
|
||||||
|
|
||||||
|
// Same instant — a cached fault would still be "fresh" here, so the clock cannot rescue this.
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||||
|
inner.Calls.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The anonymous grant lookup is cached on the same terms, keyed by principal.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Anonymous_grants_are_cached_too()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||||
|
|
||||||
|
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||||
|
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||||
|
inner.Calls.ShouldBe(1);
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(6));
|
||||||
|
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||||
|
inner.Calls.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Past the entry ceiling the cache clears wholesale and keeps answering — a key-id guessing flood
|
||||||
|
/// is a bounded memory event, not an unbounded one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Wholesale rather than per-entry eviction for the reason <c>SessionAclDecisionCache</c> already
|
||||||
|
/// documents: choosing WHICH entry to drop needs knowledge the cache does not have, and a
|
||||||
|
/// repopulate is cheap and cannot be wrong.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Cache_overflow_clears_wholesale_and_keeps_serving()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock);
|
||||||
|
|
||||||
|
// Seed the real key, then flood past the ceiling with ids that do not exist.
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||||
|
for (var i = 0; i < BoundedApiKeyStore.MaxCacheEntries + 1; i++)
|
||||||
|
await sut.FindAsync($"ghost-{i}", clock.GetUtcNow().UtcDateTime, default);
|
||||||
|
|
||||||
|
var callsAfterFlood = inner.Calls;
|
||||||
|
|
||||||
|
// Still correct after the clear — and the real key was evicted, so it is re-read rather than
|
||||||
|
// served from a cache the flood was supposed to have emptied.
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||||
|
inner.Calls.ShouldBe(callsAfterFlood + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cached key whose <c>ExpiresUtc</c> passes <b>inside</b> the TTL is not served: the store is
|
||||||
|
/// re-read instead.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Without this the TTL would resurrect an expired key for up to <c>StoreCacheSeconds</c> —
|
||||||
|
/// the exact axis 03/S19 exists to close, re-opened by 03/S18's own cache. The cache key is
|
||||||
|
/// <c>apiKeyId</c> alone (<c>utcNow</c> changes every call and would defeat the cache), so the
|
||||||
|
/// clock check has to happen on the way OUT of the cache.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The inner store is flipped to <c>Revoked</c> first, so "served from cache" and "re-read"
|
||||||
|
/// give different ANSWERS rather than only different call counts — a build that re-read and
|
||||||
|
/// then ignored the result could not pass.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_cached_key_that_expires_by_the_clock_is_not_served()
|
||||||
|
{
|
||||||
|
var start = new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
var (_, inner) = SetUp(expiresUtc: start.UtcDateTime.AddSeconds(2));
|
||||||
|
var clock = new FakeClock(start);
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock);
|
||||||
|
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default))
|
||||||
|
.ShouldNotBeNull("non-vacuity: the key was usable and IS now cached");
|
||||||
|
|
||||||
|
inner.Revoked = true;
|
||||||
|
|
||||||
|
// Past ExpiresUtc (+2 s) but far inside the 60 s TTL — the entry is still "fresh" to the cache.
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
||||||
|
inner.Calls.ShouldBe(2, "the expired entry must be re-read, not served");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- metering ----------
|
||||||
|
|
||||||
|
/// <summary>A deadline that fires meters <c>outcome=timeout</c> — the alarm signal for a sick ConfigDb.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_timeout_meters_outcome_timeout()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
inner.BlockForever = true;
|
||||||
|
using var meter = new MeterRecorder();
|
||||||
|
var sut = Bounded(inner, timeoutMs: 100, cacheSeconds: 0);
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(
|
||||||
|
() => sut.FindAsync(inner.KeyId, DateTime.UtcNow, default).WaitAsync(TimeSpan.FromSeconds(5)));
|
||||||
|
|
||||||
|
meter.WithOutcome("timeout").ShouldBe(1);
|
||||||
|
meter.WithOutcome("cache_hit").ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cache hit meters <c>outcome=cache_hit</c> — the ratio that tells an operator the TTL is
|
||||||
|
/// doing something. A missing stream on a busy fused central is how you notice
|
||||||
|
/// <c>StoreCacheSeconds</c> was set to 0 by accident.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_cache_hit_meters_outcome_cache_hit()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
using var meter = new MeterRecorder();
|
||||||
|
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||||
|
|
||||||
|
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
|
||||||
|
meter.WithOutcome("cache_hit").ShouldBe(0, "the first call is a miss");
|
||||||
|
|
||||||
|
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
|
||||||
|
meter.WithOutcome("cache_hit").ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A plain read-through — no deadline hit, no cache hit — meters nothing at all.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The counter names lookups the bound INTERVENED on. Metering every lookup would bury the two
|
||||||
|
/// outcomes that matter under the ordinary traffic they have to be spotted in.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_plain_read_through_meters_neither()
|
||||||
|
{
|
||||||
|
var (_, inner) = SetUp();
|
||||||
|
using var meter = new MeterRecorder();
|
||||||
|
var sut = Bounded(inner, timeoutMs: 2000, cacheSeconds: 0);
|
||||||
|
|
||||||
|
await sut.FindAsync(inner.KeyId, DateTime.UtcNow, default);
|
||||||
|
|
||||||
|
meter.Total.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private static BoundedApiKeyStore Bounded(
|
||||||
|
IApiKeyStore inner, int timeoutMs, int cacheSeconds, TimeProvider? clock = null) => new(
|
||||||
|
inner,
|
||||||
|
Options.Create(new ApiKeyOptions
|
||||||
|
{
|
||||||
|
Username = "apikey",
|
||||||
|
Pepper = Pepper,
|
||||||
|
StoreTimeoutMs = timeoutMs,
|
||||||
|
StoreCacheSeconds = cacheSeconds,
|
||||||
|
}),
|
||||||
|
NullLogger<BoundedApiKeyStore>.Instance,
|
||||||
|
clock ?? TimeProvider.System);
|
||||||
|
|
||||||
|
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
|
||||||
|
store,
|
||||||
|
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
|
||||||
|
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
private static (string Presented, GatedApiKeyStore Store) SetUp(DateTime? expiresUtc = null)
|
||||||
|
{
|
||||||
|
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
|
||||||
|
var key = new ApiKey
|
||||||
|
{
|
||||||
|
ApiKeyId = apiKeyId,
|
||||||
|
Name = "test key",
|
||||||
|
SecretHash = hash,
|
||||||
|
Principal = Principal,
|
||||||
|
Enabled = true,
|
||||||
|
CreatedUtc = DateTime.UtcNow,
|
||||||
|
ExpiresUtc = expiresUtc,
|
||||||
|
};
|
||||||
|
return (presented, new GatedApiKeyStore(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An inner store whose answer can be withheld — a hang the EF in-memory provider cannot
|
||||||
|
/// represent — and whose calls are counted.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class GatedApiKeyStore(ApiKey key) : IApiKeyStore
|
||||||
|
{
|
||||||
|
/// <summary>The id of the key this store holds.</summary>
|
||||||
|
public string KeyId => key.ApiKeyId;
|
||||||
|
|
||||||
|
/// <summary>When set, every call awaits this before answering.</summary>
|
||||||
|
public Task? Gate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, no call ever completes.</summary>
|
||||||
|
public bool BlockForever { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How many calls reached this store (i.e. were not served from the cache).</summary>
|
||||||
|
public int Calls => _calls;
|
||||||
|
|
||||||
|
/// <summary>When true, the key lookup answers "no usable key" — a revocation.</summary>
|
||||||
|
public bool Revoked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of leading calls that throw before the store starts answering.</summary>
|
||||||
|
public int ThrowsRemaining { get; set; }
|
||||||
|
|
||||||
|
private int _calls;
|
||||||
|
|
||||||
|
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
if (Revoked) return null;
|
||||||
|
return string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
|
||||||
|
? new ApiKeyLookup(key, [])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _calls);
|
||||||
|
|
||||||
|
if (ThrowsRemaining > 0)
|
||||||
|
{
|
||||||
|
ThrowsRemaining--;
|
||||||
|
throw new InvalidOperationException("store is down");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BlockForever) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||||
|
if (Gate is { } gate) await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collects <c>otopcua.opcua.apikey_store.bounded</c> measurements by their outcome tag.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ⚠️ Filtered by instrument name only, which is normally the metrics-test leak (a listener sees
|
||||||
|
/// a PARALLEL test's measurements too). It is safe HERE for two checkable reasons:
|
||||||
|
/// <see cref="BoundedApiKeyStore"/> is the only thing in the product that writes this instrument,
|
||||||
|
/// and this assembly declares <c>CollectionBehavior(DisableTestParallelization = true)</c> in
|
||||||
|
/// <c>AssemblyInfo.cs</c>, so there is no parallel test to leak from. Removing that attribute
|
||||||
|
/// means adding a discriminating tag here.
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class MeterRecorder : IDisposable
|
||||||
|
{
|
||||||
|
private readonly MeterListener _listener;
|
||||||
|
private readonly List<KeyValuePair<string, object?>[]> _tagSets = [];
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private long _total;
|
||||||
|
|
||||||
|
public MeterRecorder()
|
||||||
|
{
|
||||||
|
_listener = new MeterListener
|
||||||
|
{
|
||||||
|
InstrumentPublished = (instrument, listener) =>
|
||||||
|
{
|
||||||
|
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName
|
||||||
|
&& instrument.Name == "otopcua.opcua.apikey_store.bounded")
|
||||||
|
{
|
||||||
|
listener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_total += value;
|
||||||
|
_tagSets.Add(tags.ToArray());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_listener.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every measurement seen, regardless of tags.</summary>
|
||||||
|
public long Total
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _total; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many measurements carried <c>outcome=<paramref name="outcome"/></c>.</summary>
|
||||||
|
/// <param name="outcome">The outcome tag value to count.</param>
|
||||||
|
/// <returns>The measurement count.</returns>
|
||||||
|
public int WithOutcome(string outcome)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _tagSets.Count(set => set.Any(t => t.Key == "outcome" && Equals(t.Value, outcome)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose() => _listener.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A hand-advanced clock. Overrides <c>GetUtcNow()</c> only — see the class remarks for why that
|
||||||
|
/// confines it to the TTL cases.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _now = utcNow;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override DateTimeOffset GetUtcNow() => _now;
|
||||||
|
|
||||||
|
/// <summary>Moves the clock forward.</summary>
|
||||||
|
/// <param name="by">How far forward to move.</param>
|
||||||
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Hosting;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the ACL revalidation timer's lifecycle on the hosted service (arch-review 03/S19) — that it is
|
||||||
|
/// armed at the configured interval, that <c>0</c> arms nothing, and that stopping disposes it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The timer is armed through <see cref="OtOpcUaServerHostedService.StartRevalidationTimer"/> rather
|
||||||
|
/// than by running <c>StartAsync</c>, because <c>StartAsync</c> boots the real OPC UA SDK (certificate
|
||||||
|
/// store, listening socket) and none of that is what this is about. Everything the timer's lifecycle
|
||||||
|
/// touches — the options, the clock, the disposal — is exercised exactly as production reaches it.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OtOpcUaServerHostedServiceRevalidationTests
|
||||||
|
{
|
||||||
|
/// <summary>The configured interval is what the timer is armed at.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_configured_interval_arms_one_timer_at_that_period()
|
||||||
|
{
|
||||||
|
var clock = new RecordingTimeProvider();
|
||||||
|
var sut = Service(clock, intervalSeconds: 30);
|
||||||
|
|
||||||
|
sut.StartRevalidationTimer();
|
||||||
|
|
||||||
|
clock.Periods.ShouldBe([TimeSpan.FromSeconds(30)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>RevalidationIntervalSeconds = 0</c> arms nothing — the documented rollback to
|
||||||
|
/// deploy-triggered-only revalidation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_zero_interval_arms_nothing()
|
||||||
|
{
|
||||||
|
var clock = new RecordingTimeProvider();
|
||||||
|
var sut = Service(clock, intervalSeconds: 0);
|
||||||
|
|
||||||
|
sut.StartRevalidationTimer();
|
||||||
|
|
||||||
|
clock.Periods.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>StopAsync</c> disposes the timer, so no tick can land on a node manager that is being torn
|
||||||
|
/// down.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The disposal is the FIRST thing <c>StopAsync</c> does, ahead of
|
||||||
|
/// <c>SetDataPlaneAuthority(null)</c> / <c>SetDataPlaneAuthorizer(null)</c> — a stopping node sheds
|
||||||
|
/// the trigger before it sheds the thing the trigger drives.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task StopAsync_disposes_the_timer()
|
||||||
|
{
|
||||||
|
var clock = new RecordingTimeProvider();
|
||||||
|
var sut = Service(clock, intervalSeconds: 30);
|
||||||
|
sut.StartRevalidationTimer();
|
||||||
|
|
||||||
|
clock.Timers[0].Disposed.ShouldBeFalse("non-vacuity: the timer is live before the stop");
|
||||||
|
|
||||||
|
await sut.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
clock.Timers[0].Disposed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposing the service disposes the timer too, for a host that never called StopAsync.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposeAsync_disposes_the_timer()
|
||||||
|
{
|
||||||
|
var clock = new RecordingTimeProvider();
|
||||||
|
var sut = Service(clock, intervalSeconds: 30);
|
||||||
|
sut.StartRevalidationTimer();
|
||||||
|
|
||||||
|
await sut.DisposeAsync();
|
||||||
|
|
||||||
|
clock.Timers[0].Disposed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private static OtOpcUaServerHostedService Service(TimeProvider clock, int intervalSeconds) => new(
|
||||||
|
Options.Create(new OpcUaApplicationHostOptions()),
|
||||||
|
new DeferredAddressSpaceSink(),
|
||||||
|
new DeferredServiceLevelPublisher(),
|
||||||
|
NullOpcUaUserAuthenticator.Instance,
|
||||||
|
NullHistorianDataSource.Instance,
|
||||||
|
EmptyDataPlaneAuthoritySource.Instance,
|
||||||
|
new ConfigurationBuilder().Build(),
|
||||||
|
() => throw new NotSupportedException("no ActorSystem is needed for the timer lifecycle"),
|
||||||
|
new ActorRegistry(),
|
||||||
|
dataPlaneAuthorizer: null,
|
||||||
|
Options.Create(new AclOptions { RevalidationIntervalSeconds = intervalSeconds }),
|
||||||
|
clock,
|
||||||
|
NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
/// <summary>Records the timers asked of it instead of arming real ones.</summary>
|
||||||
|
private sealed class RecordingTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
/// <summary>Every timer this provider was asked to create, in order.</summary>
|
||||||
|
public List<FakeTimer> Timers { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The period each of those timers was armed at.</summary>
|
||||||
|
public IReadOnlyList<TimeSpan> Periods => [.. Timers.Select(t => t.Period)];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||||
|
{
|
||||||
|
var timer = new FakeTimer(period);
|
||||||
|
Timers.Add(timer);
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A timer that remembers its period and whether it was disposed.</summary>
|
||||||
|
internal sealed class FakeTimer(TimeSpan period) : ITimer
|
||||||
|
{
|
||||||
|
public TimeSpan Period { get; } = period;
|
||||||
|
|
||||||
|
public bool Disposed { get; private set; }
|
||||||
|
|
||||||
|
public bool Change(TimeSpan dueTime, TimeSpan p) => true;
|
||||||
|
|
||||||
|
public void Dispose() => Disposed = true;
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Disposed = true;
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+370
@@ -0,0 +1,370 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// arch-review 03/S19 over the wire — a key that expires by the CLOCK stops a live subscription, with
|
||||||
|
/// no deployment anywhere in the run.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This cannot be reasoned into existence for the same reason
|
||||||
|
/// <c>AclSubscriptionEnforcementTests</c> gives: whether a monitored item can recover the identity
|
||||||
|
/// of the session that created it is an assumption about SDK behaviour. Here it must recover an
|
||||||
|
/// identity carrying an <b>API key id</b> — the field the per-call expiry re-check keys on — and
|
||||||
|
/// the existing rig has never exercised one, because it connects anonymously.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The tick is driven through the real <see cref="AclRevalidationTimer"/> rather than by calling
|
||||||
|
/// the node manager's pass directly, so what is asserted here is the whole T8 path: revert the
|
||||||
|
/// host's timer and the denial below cannot be produced by anything except a deployment.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AclKeyExpiryEnforcementTests
|
||||||
|
{
|
||||||
|
private const string Cluster = "SITE-A";
|
||||||
|
private const string Device = "Plant/Modbus/dev1";
|
||||||
|
private const string Tag = "Plant/Modbus/dev1/Speed";
|
||||||
|
private const string Principal = "expiry-probe";
|
||||||
|
private const string KeyId = "k-expiry";
|
||||||
|
|
||||||
|
private static readonly DateTime Start = new(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// THE test this file exists for: the key's <c>ExpiresUtc</c> passes, a revalidation tick runs, and
|
||||||
|
/// the client is told <c>BadUserAccessDenied</c> — with <c>DataPlaneAuthorityState.Apply</c> called
|
||||||
|
/// exactly once, in setup.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_expired_key_stops_a_live_subscription_with_no_deploy()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var rig = await Rig.StartAsync("AclKeyExpiry", ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rig.Enforce(expiresUtc: Start.AddMinutes(10));
|
||||||
|
rig.Materialise();
|
||||||
|
rig.Write(1);
|
||||||
|
|
||||||
|
using var session = await rig.ConnectAsync(ct);
|
||||||
|
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
||||||
|
|
||||||
|
// Baseline: the key-carrying session's feed is live.
|
||||||
|
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
||||||
|
received.Clear();
|
||||||
|
|
||||||
|
// Only the clock moves. No deployment, no new generation, no grant change.
|
||||||
|
rig.Advance(TimeSpan.FromMinutes(20));
|
||||||
|
rig.TickRevalidation();
|
||||||
|
|
||||||
|
await WaitUntilAsync(
|
||||||
|
() => received.Any(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied),
|
||||||
|
TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
// And the data really has stopped.
|
||||||
|
received.Clear();
|
||||||
|
rig.Write(2);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
||||||
|
received.ShouldNotContain(v => Equals(v.Value, 2f));
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally { await rig.DisposeAsync(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negative control: identical run, clock advanced but not past <c>ExpiresUtc</c> ⇒ values keep
|
||||||
|
/// arriving.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Without it the test above also passes on a build whose tick detaches every item it sees, which
|
||||||
|
/// would silence every subscription in the fleet once a minute.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unexpired_key_keeps_streaming_across_a_tick()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var rig = await Rig.StartAsync("AclKeyLive", ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rig.Enforce(expiresUtc: Start.AddHours(4));
|
||||||
|
rig.Materialise();
|
||||||
|
rig.Write(1);
|
||||||
|
|
||||||
|
using var session = await rig.ConnectAsync(ct);
|
||||||
|
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
||||||
|
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
received.Clear();
|
||||||
|
rig.Advance(TimeSpan.FromMinutes(20));
|
||||||
|
rig.TickRevalidation();
|
||||||
|
|
||||||
|
rig.Write(2);
|
||||||
|
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
|
||||||
|
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally { await rig.DisposeAsync(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── helpers ─────────────────────────────
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
|
||||||
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition()) return;
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
condition().ShouldBeTrue("condition not met within timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A booted server, a key-carrying client session, and a hand-driven revalidation tick.</summary>
|
||||||
|
private sealed class Rig : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly string _pkiRoot;
|
||||||
|
private readonly FakeClock _clock = new(Start);
|
||||||
|
private OpcUaApplicationHost _host = null!;
|
||||||
|
private OtOpcUaSdkServer _server = null!;
|
||||||
|
private AclRevalidationTimer? _timer;
|
||||||
|
private int _port;
|
||||||
|
|
||||||
|
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
|
||||||
|
|
||||||
|
private OtOpcUaNodeManager Nm => _server.NodeManager!;
|
||||||
|
|
||||||
|
public static async Task<Rig> StartAsync(string name, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
|
||||||
|
rig._port = AllocateFreePort();
|
||||||
|
rig._server = new OtOpcUaSdkServer();
|
||||||
|
rig._host = new OpcUaApplicationHost(
|
||||||
|
new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = $"OtOpcUa.{name}",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
|
||||||
|
OpcUaPort = rig._port,
|
||||||
|
PublicHostname = "127.0.0.1",
|
||||||
|
PkiStoreRoot = rig._pkiRoot,
|
||||||
|
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
},
|
||||||
|
NullLogger<OpcUaApplicationHost>.Instance,
|
||||||
|
new KeyPresentingAuthenticator());
|
||||||
|
|
||||||
|
await rig._host.StartAsync(rig._server, ct);
|
||||||
|
return rig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switches enforcement on with one cluster-wide grant and one key expiring at
|
||||||
|
/// <paramref name="expiresUtc"/>, and arms the revalidation timer over the same clock.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="expiresUtc">When the key stops being usable.</param>
|
||||||
|
public void Enforce(DateTime expiresUtc)
|
||||||
|
{
|
||||||
|
var key = new ApiKey
|
||||||
|
{
|
||||||
|
ApiKeyId = KeyId,
|
||||||
|
Name = KeyId,
|
||||||
|
SecretHash = "hash",
|
||||||
|
Principal = Principal,
|
||||||
|
Enabled = true,
|
||||||
|
CreatedUtc = Start.AddDays(-1),
|
||||||
|
ExpiresUtc = expiresUtc,
|
||||||
|
};
|
||||||
|
var grant = new NodeAcl
|
||||||
|
{
|
||||||
|
NodeAclId = "acl-cluster",
|
||||||
|
ClusterId = Cluster,
|
||||||
|
Principal = Principal,
|
||||||
|
ScopeKind = NodeAclScopeKind.Cluster,
|
||||||
|
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
|
||||||
|
};
|
||||||
|
|
||||||
|
var state = new DataPlaneAuthorityState();
|
||||||
|
|
||||||
|
// The ONLY Apply in the whole test. Everything after this moves the clock.
|
||||||
|
state.Apply(Cluster, "rev-1", [key], [grant], NoScopes);
|
||||||
|
|
||||||
|
Nm.DataPlaneAuthority = state;
|
||||||
|
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||||
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock);
|
||||||
|
|
||||||
|
// A long interval: the tick is driven by hand, so a real timer firing underneath would only
|
||||||
|
// make the test non-deterministic. What matters is that the pass is reached through the same
|
||||||
|
// component the hosted service arms.
|
||||||
|
_timer = new AclRevalidationTimer(
|
||||||
|
() => _server.NodeManager?.RevalidateMonitoredItems(),
|
||||||
|
TimeSpan.FromHours(1),
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the authorizer's clock forward — the only thing that changes mid-test.</summary>
|
||||||
|
/// <param name="by">How far forward.</param>
|
||||||
|
public void Advance(TimeSpan by) => _clock.Advance(by);
|
||||||
|
|
||||||
|
/// <summary>Runs one revalidation tick through the host's own timer component.</summary>
|
||||||
|
public void TickRevalidation() => _timer!.Tick();
|
||||||
|
|
||||||
|
public void Materialise()
|
||||||
|
{
|
||||||
|
Nm.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw);
|
||||||
|
Nm.EnsureFolder("Plant/Modbus", "Plant", "Modbus", AddressSpaceRealm.Raw);
|
||||||
|
Nm.EnsureFolder(Device, "Plant/Modbus", "dev1", AddressSpaceRealm.Raw);
|
||||||
|
Nm.EnsureVariable(Tag, Device, "Speed", "Float", writable: false, AddressSpaceRealm.Raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(float value) =>
|
||||||
|
Nm.WriteValue(Tag, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
public ushort RawNamespace(ISession session)
|
||||||
|
{
|
||||||
|
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
ns.ShouldBeGreaterThan((ushort)0);
|
||||||
|
return ns;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NodeId TagNodeId(ISession session) => new(Tag, RawNamespace(session));
|
||||||
|
|
||||||
|
public async Task<(Subscription Subscription, List<DataValue> Received)> SubscribeAsync(
|
||||||
|
ISession session, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var received = new List<DataValue>();
|
||||||
|
var gate = new object();
|
||||||
|
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
var item = new MonitoredItem(subscription.DefaultItem)
|
||||||
|
{
|
||||||
|
StartNodeId = TagNodeId(session),
|
||||||
|
AttributeId = Attributes.Value,
|
||||||
|
SamplingInterval = 50,
|
||||||
|
QueueSize = 10,
|
||||||
|
};
|
||||||
|
item.Notification += (mi, _) =>
|
||||||
|
{
|
||||||
|
foreach (var v in mi.DequeueValues()) lock (gate) received.Add(v);
|
||||||
|
};
|
||||||
|
subscription.AddItem(item);
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
item.Status.Error.ShouldBeNull();
|
||||||
|
return (subscription, received);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Connects presenting the fixed <c>apikey</c> username with an <c>otua_</c> credential.</summary>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>The connected session.</returns>
|
||||||
|
public async Task<ISession> ConnectAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var appConfig = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.AclKeyClient",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.AclKeyClient.{Guid.NewGuid():N}",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||||
|
};
|
||||||
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||||
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||||
|
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
||||||
|
|
||||||
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||||
|
appConfig,
|
||||||
|
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
||||||
|
updateBeforeConnect: false,
|
||||||
|
checkDomain: false,
|
||||||
|
sessionName: "AclKeyExpiryEnforcementTests",
|
||||||
|
sessionTimeout: 60_000,
|
||||||
|
identity: new UserIdentity(new UserNameIdentityToken
|
||||||
|
{
|
||||||
|
UserName = "apikey",
|
||||||
|
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
|
||||||
|
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
|
||||||
|
}),
|
||||||
|
preferredLocales: null,
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
await _host.DisposeAsync();
|
||||||
|
if (Directory.Exists(_pkiRoot))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||||
|
catch (IOException) { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admits the fixed <c>apikey</c> username as <see cref="Principal"/>, carrying
|
||||||
|
/// <see cref="KeyId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately not the production <c>ApiKeyOpcUaUserAuthenticator</c> over a fake store: the
|
||||||
|
/// credential-verification path (parse, peppered HMAC, store lookup, deny shapes) is covered by
|
||||||
|
/// <c>ApiKeyAuthenticationTests</c> and <c>BoundedApiKeyStoreTests</c> in the Host suite, and
|
||||||
|
/// none of it changes what this file is about — whether the SDK hands a monitored item back an
|
||||||
|
/// identity that still carries the key id. Grants come from the deployed snapshot, so this
|
||||||
|
/// returns no roles.
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
||||||
|
string username, string password, CancellationToken ct) =>
|
||||||
|
Task.FromResult(
|
||||||
|
string.Equals(username, "apikey", StringComparison.Ordinal)
|
||||||
|
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
|
||||||
|
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId)
|
||||||
|
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
|
||||||
|
private sealed class FakeClock(DateTime utcNow) : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override DateTimeOffset GetUtcNow() => _now;
|
||||||
|
|
||||||
|
/// <summary>Moves the clock forward.</summary>
|
||||||
|
/// <param name="by">How far forward.</param>
|
||||||
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -25,6 +25,10 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
|
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
|
||||||
|
<!-- AclKeyExpiryEnforcementTests drives the real AclRevalidationTimer (arch-review 03/S19), so the
|
||||||
|
over-the-wire denial is produced by the same component the hosted service arms rather than by
|
||||||
|
a direct call to the node manager's pass. -->
|
||||||
|
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+148
@@ -615,8 +615,156 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
|||||||
finally { await host.DisposeAsync(); }
|
finally { await host.DisposeAsync(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A key that expires by the CLOCK stands its live monitored item down, with no deployment
|
||||||
|
/// anywhere in the test (arch-review 03/S19).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The first pass is the falsification control, and it is not optional: without it a build
|
||||||
|
/// whose pass detached everything it saw — unconditionally, or because the authorizer denied
|
||||||
|
/// for some unrelated reason — would pass the second half and prove nothing about expiry.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>DataPlaneAuthorityState.Apply</c> is called exactly once here, in setup. Nothing
|
||||||
|
/// advances the generation between the two passes, so the only thing that changed is the
|
||||||
|
/// clock — which is the whole claim.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_expired_key_stands_down_a_live_item_with_no_deploy()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
|
||||||
|
nm.Authorizer = KeyedSubscribeAuthorizer(
|
||||||
|
clock, out _, expiresUtc: new DateTime(2026, 8, 20, 12, 10, 0, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
|
||||||
|
|
||||||
|
nm.RevalidateMonitoredItems();
|
||||||
|
item.Denials.ShouldBe(0, "the key is still valid, so nothing may stand down");
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromMinutes(20));
|
||||||
|
nm.RevalidateMonitoredItems();
|
||||||
|
|
||||||
|
item.Denials.ShouldBe(1);
|
||||||
|
}
|
||||||
|
finally { await host.DisposeAsync(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A key renewed with a later expiry re-attaches its item on the next pass — the clock axis
|
||||||
|
/// inherits the deploy axis's reversibility.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The restore is asserted by standing the item down a SECOND time: the pass skips an item that is
|
||||||
|
/// already suppressed, so a second denial can only be queued if the item was genuinely restored
|
||||||
|
/// in between. That is why this pins the design decision not to close the session — a client that
|
||||||
|
/// renews its key recovers with no action of its own.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_renewed_key_re_attaches_the_item_on_the_next_pass()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
|
||||||
|
nm.Authorizer = KeyedSubscribeAuthorizer(
|
||||||
|
clock, out var state, expiresUtc: new DateTime(2026, 8, 20, 12, 10, 0, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromMinutes(20));
|
||||||
|
nm.RevalidateMonitoredItems();
|
||||||
|
item.Denials.ShouldBe(1, "non-vacuity: the item really was stood down before the renewal");
|
||||||
|
|
||||||
|
// Renew: same key id, a later expiry.
|
||||||
|
state.Apply(
|
||||||
|
Cluster, "rev-2",
|
||||||
|
[Key(new DateTime(2026, 8, 20, 14, 0, 0, DateTimeKind.Utc))],
|
||||||
|
[GrantOn(NodePermissions.Subscribe)],
|
||||||
|
Scopes());
|
||||||
|
nm.RevalidateMonitoredItems();
|
||||||
|
|
||||||
|
// Expire it again. A still-suppressed item is skipped, so a second denial proves the restore.
|
||||||
|
clock.Advance(TimeSpan.FromHours(3));
|
||||||
|
nm.RevalidateMonitoredItems();
|
||||||
|
|
||||||
|
item.Denials.ShouldBe(2);
|
||||||
|
}
|
||||||
|
finally { await host.DisposeAsync(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A clock that moves but does not reach the expiry stands nothing down.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unexpired_key_is_untouched_by_a_clock_only_pass()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
|
||||||
|
nm.Authorizer = KeyedSubscribeAuthorizer(
|
||||||
|
clock, out _, expiresUtc: new DateTime(2026, 8, 20, 13, 0, 0, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromMinutes(30));
|
||||||
|
nm.RevalidateMonitoredItems();
|
||||||
|
|
||||||
|
item.Denials.ShouldBe(0);
|
||||||
|
}
|
||||||
|
finally { await host.DisposeAsync(); }
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────── helpers ─────────────────────────────
|
// ───────────────────────────── helpers ─────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>The public id of the key the clock-driven expiry cases authenticate with.</summary>
|
||||||
|
private const string ExpiringKeyId = "k-expiry";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cluster-wide Subscribe grant PLUS a key that expires at <paramref name="expiresUtc"/>, so the
|
||||||
|
/// verdict moves with the clock rather than with a deployment.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clock">The authorizer's clock — the one <c>Evaluate</c> checks expiry against.</param>
|
||||||
|
/// <param name="state">The authority state, so a test can renew the key without a second helper.</param>
|
||||||
|
/// <param name="expiresUtc">When the key stops being usable.</param>
|
||||||
|
/// <returns>The gate, enforcing.</returns>
|
||||||
|
private static DataPlaneAuthorizer KeyedSubscribeAuthorizer(
|
||||||
|
TimeProvider clock, out DataPlaneAuthorityState state, DateTime expiresUtc)
|
||||||
|
{
|
||||||
|
state = new DataPlaneAuthorityState();
|
||||||
|
state.Apply(Cluster, "rev-1", [Key(expiresUtc)], [GrantOn(NodePermissions.Subscribe)], Scopes());
|
||||||
|
return new DataPlaneAuthorizer(
|
||||||
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The expiring key, at whatever expiry a case needs.</summary>
|
||||||
|
/// <param name="expiresUtc">When the key stops being usable.</param>
|
||||||
|
/// <returns>The key row.</returns>
|
||||||
|
private static ApiKey Key(DateTime expiresUtc) => new()
|
||||||
|
{
|
||||||
|
ApiKeyId = ExpiringKeyId,
|
||||||
|
Name = ExpiringKeyId,
|
||||||
|
SecretHash = "hash",
|
||||||
|
Principal = Principal,
|
||||||
|
Enabled = true,
|
||||||
|
CreatedUtc = new DateTime(2026, 8, 20, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
ExpiresUtc = expiresUtc,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An identity carrying the key id, which is what makes the per-call expiry re-check apply — an
|
||||||
|
/// identity with no <c>ApiKeyId</c> skips it entirely.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The identity.</returns>
|
||||||
|
private static RoleCarryingUserIdentity KeyedIdentity() =>
|
||||||
|
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, ExpiringKeyId);
|
||||||
|
|
||||||
/// <summary>A cluster-wide Subscribe grant — what <c>CreateMonitoredItems</c> resolves to.</summary>
|
/// <summary>A cluster-wide Subscribe grant — what <c>CreateMonitoredItems</c> resolves to.</summary>
|
||||||
private static DataPlaneAuthorizer SubscribeAuthorizer(TimeProvider clock)
|
private static DataPlaneAuthorizer SubscribeAuthorizer(TimeProvider clock)
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -137,7 +137,7 @@ public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBa
|
|||||||
},
|
},
|
||||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||||||
|
|
||||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, AuditDelay);
|
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
|
||||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(SilentDriver, Connected: false));
|
actor.Tell(new DriverInstanceActor.ConnectivityChanged(SilentDriver, Connected: false));
|
||||||
@@ -170,7 +170,7 @@ public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBa
|
|||||||
},
|
},
|
||||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||||||
|
|
||||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, AuditDelay);
|
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
|
||||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(RepublishingDriver, Connected: false));
|
actor.Tell(new DriverInstanceActor.ConnectivityChanged(RepublishingDriver, Connected: false));
|
||||||
@@ -211,7 +211,7 @@ public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBa
|
|||||||
},
|
},
|
||||||
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||||
|
|
||||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, AuditDelay);
|
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
|
||||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(PartialDriver, Connected: false));
|
actor.Tell(new DriverInstanceActor.ConnectivityChanged(PartialDriver, Connected: false));
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The repo's first driver-child <b>restart</b> suite (archreview 03/S16, 03/U6): what a child obtained
|
||||||
|
/// by each of the three incarnation routes is actually handed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The falsification bar, and why this file exists at all.</b> An actor-restart test must assert a
|
||||||
|
/// FUNCTION the new incarnation performs — it subscribed, it re-initialised — never that the
|
||||||
|
/// incarnation EXISTS. Existence is exactly what
|
||||||
|
/// <c>DriverHostActorReconcileTests.RestartDriver_respawns_the_child_without_an_actor_name_collision</c>
|
||||||
|
/// asserts (no name collision, factory created a second driver, one child remains), and that test
|
||||||
|
/// passes green against the 03/S16 defect: a respawned child that connects and then reads nothing
|
||||||
|
/// forever. It is kept, because its own subject — the <c>InvalidActorNameException</c> — is real; it
|
||||||
|
/// is simply the weaker half of a pair, and this file is the other half.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The three incarnation routes, and what re-provisions each.</b> (1) <i>Deploy reconcile</i> —
|
||||||
|
/// <c>ApplyDriverSpecs</c> → <c>SpawnChild</c>, then the apply's bulk desired-subscription push.
|
||||||
|
/// (2) <i>Operator Restart</i> — <c>HandleRestartDriver</c> stops the child and calls
|
||||||
|
/// <c>SpawnChild</c> with the cached spec; before 03/S16 that was the whole story, and the new child
|
||||||
|
/// started with an EMPTY desired set that nothing re-sent until the next deployment (the #488
|
||||||
|
/// periodic reconcile early-returns on an empty set, so it is structurally inert here).
|
||||||
|
/// (3) <i>Supervision restart</i> — Akka's default decider recreates the actor object around the
|
||||||
|
/// SAME driver instance without going through <c>SpawnChild</c> at all, so nothing re-sent
|
||||||
|
/// <c>InitializeRequested</c> either and the child sat in <c>Connecting</c> forever around a driver
|
||||||
|
/// its own <c>PostStop</c> had already shut down. A fourth route, <i>Force Reconnect</i>, re-uses the
|
||||||
|
/// live actor instance and needs nothing — a verified negative, pinned here so a future edit to the
|
||||||
|
/// restart path cannot break the path that was already correct.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The supervision crash seam, and why the obvious ones do not work.</b> The seam used is the
|
||||||
|
/// driver's own <c>OnDataChange</c> carrying a NULL <c>Snapshot</c>
|
||||||
|
/// (<see cref="RecordingSubscribableDriver.FireMalformedDataChange"/>): <c>DataChangeEventArgs</c> is
|
||||||
|
/// a positional record with no null validation, and <c>DriverInstanceActor.OnDataChangeForward</c>
|
||||||
|
/// dereferences <c>msg.Snapshot</c> unguarded on the ACTOR thread, so it NREs inside the child and
|
||||||
|
/// the default decider Restarts it. Rejected alternatives, each for a specific reason: a throwing
|
||||||
|
/// <c>IDriverHealthPublisher</c> (<c>PublishHealthSnapshot</c> wraps the publish in a
|
||||||
|
/// <c>catch (Exception)</c>, so it never escapes); a throwing <c>SubscribeAsync</c> (also swallowed —
|
||||||
|
/// the stub already models it via <c>SubscribeFailuresBeforeSuccess</c>); <c>Kill.Instance</c> (the
|
||||||
|
/// default decider <b>Stops</b> on <c>ActorKilledException</c>, a different directive and therefore a
|
||||||
|
/// different code path); and an <c>internal</c> "throw this" test message (a production surface added
|
||||||
|
/// purely for a test). The fault is arbitrary by design — what is under test is the RECOVERY. If
|
||||||
|
/// <c>DataChangeEventArgs</c> ever gains null validation, the fallback is an explicit
|
||||||
|
/// <c>OneForOneStrategy(Directive.Restart)</c> on the host plus an internal test-only throw message.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverHostActorRestartTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||||
|
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
|
||||||
|
private static readonly NodeId TestNode = NodeId.Parse("node-1:4053");
|
||||||
|
|
||||||
|
private const string DriverId = "DI-1";
|
||||||
|
private const string SpeedPath = "Plant/Modbus/dev1/speed";
|
||||||
|
private const string TempPath = "Plant/Modbus/dev1/temp";
|
||||||
|
private const string AlarmXPath = "Plant/Modbus/dev1/alarmX";
|
||||||
|
private const string AlarmYPath = "Plant/Modbus/dev1/alarmY";
|
||||||
|
|
||||||
|
/// <summary>TagConfig marking a raw tag as alarm-bearing (parsed by <c>TagConfigIntent</c>).</summary>
|
||||||
|
private const string AlarmConfig = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""";
|
||||||
|
|
||||||
|
/// <summary>Seeds + applies a v3 deployment whose single Modbus driver is ENABLED, so a real child is
|
||||||
|
/// spawned around a <see cref="RecordingSubscribableDriver"/>, and waits until that child has connected
|
||||||
|
/// and established its first subscription.</summary>
|
||||||
|
private (IActorRef Host, RecordingSubscribableDriverFactory Factory) DeployOneDriver(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||||
|
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
||||||
|
IReadOnlyDictionary<string, string>? tagConfigByTag = null)
|
||||||
|
{
|
||||||
|
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
||||||
|
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||||
|
db, RevA, rawTags, Array.Empty<(string, string, string, string, string?)>(),
|
||||||
|
enabled: true, tagConfigByTag: tagConfigByTag);
|
||||||
|
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
||||||
|
return (host, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The 03/S16 falsification.</b> An operator's Restart is supposed to recover a sick driver; before
|
||||||
|
/// the fix it silently killed it — the respawned child came up with <c>_desiredRefs</c> empty, and the
|
||||||
|
/// only <c>SetDesiredSubscriptions</c> sender in the repo was the apply's bulk push, so the driver
|
||||||
|
/// subscribed to nothing until the next deployment dispatch.
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately NOT the pass criterion: that a second driver was created, or that one child
|
||||||
|
/// remains. Both are true against the defect.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Operator_restart_re_subscribes_the_respawned_child()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].SubscribeCount.ShouldBe(1);
|
||||||
|
factory.Created[0].LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(2, "the restart did not respawn the child around a new driver");
|
||||||
|
factory.Created[1].SubscribeCount.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
1,
|
||||||
|
"the RESPAWNED child never subscribed — it is Connected and reading nothing, which is the " +
|
||||||
|
"03/S16 defect (an operator pressed Restart to recover a driver and silently killed it)");
|
||||||
|
factory.Created[1].LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The value refs and the alarm refs ride in ONE <c>SetDesiredSubscriptions</c> but are applied by two
|
||||||
|
/// different child code paths (the value set self-Tells <c>Subscribe</c>; the alarm set runs
|
||||||
|
/// <c>SubscribeDesiredAlarms</c> against the <c>IAlarmSource</c> feed, which stays gated shut until it
|
||||||
|
/// is called). Sending the message is therefore not evidence that both halves landed, so the alarm
|
||||||
|
/// half is pinned separately.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Operator_restart_re_subscribes_alarm_refs_too()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var (host, factory) = DeployOneDriver(
|
||||||
|
db,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
(DriverId, "Modbus", "dev1", "speed", "Float", false),
|
||||||
|
(DriverId, "Modbus", "dev1", "temp", "Float", false),
|
||||||
|
},
|
||||||
|
// An alarm-bearing raw tag is EXCLUDED from the value set and carried in the alarm set instead.
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["temp"] = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""",
|
||||||
|
});
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||||
|
factory.Created[0].LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath });
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(2);
|
||||||
|
factory.Created[1].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
1,
|
||||||
|
"the respawned child never re-subscribed its alarm refs — the IAlarmSource feed stays " +
|
||||||
|
"gated shut, so no native alarm transition can ever reach the host again");
|
||||||
|
factory.Created[1].LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath });
|
||||||
|
// The value half must survive the alarm half being present.
|
||||||
|
factory.Created[1].LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ordering guard for the spawn-time send. <c>ApplyDriverSpecs</c> (which spawns) runs BEFORE
|
||||||
|
/// <c>ApplyDesiredSubscriptions</c> (which rebuilds the authority), so a child spawned by a fresh
|
||||||
|
/// deploy is momentarily handed the PREVIOUS apply's set. That transient must never be acted on: it
|
||||||
|
/// is superseded by the bulk push on the same sender→receiver pair (Akka guarantees per-pair
|
||||||
|
/// ordering) while the child is still <c>Connecting</c>, where <c>SetDesiredSubscriptions</c> only
|
||||||
|
/// STORES; the subscribe happens on the Connected entry, from whatever was stored last.
|
||||||
|
/// <para>
|
||||||
|
/// Revision B swaps the driver's tag, so A's and B's sets are distinguishable. Note the raw tags
|
||||||
|
/// are folded into the merged <c>DriverConfig</c>, so a tag-set change DOES change the driver
|
||||||
|
/// spec and therefore respawns the child (#516) — which is precisely the arrangement under test:
|
||||||
|
/// a brand-new child, spawned while the authority still holds A, must end up subscribed to B.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What is asserted is CONVERGENCE, not inertness</b>, and the difference was measured rather
|
||||||
|
/// than assumed. The child runs concurrently with the rest of the apply handler, so a driver
|
||||||
|
/// whose <c>InitializeAsync</c> completes instantly (this stub does) can reach Connected and
|
||||||
|
/// subscribe the transient stale set BEFORE the bulk push arrives — observed here as
|
||||||
|
/// <c>SubscribeCount == 2</c>. Akka's per-pair ordering still guarantees the final state is B's,
|
||||||
|
/// and <c>HandleSubscribeAsync</c> drops the prior subscription before establishing the new one,
|
||||||
|
/// so the cost is one extra round-trip on a respawning deploy. Pinning the count would pin a
|
||||||
|
/// race; the final refs are the invariant.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_child_spawned_by_a_fresh_deploy_ends_with_the_NEW_refs()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() => factory.Created[0].LastSubscribedRefs.ShouldBe(new[] { SpeedPath }),
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
var revB = V3ArtifactSeeder.SeedV3Deployment(
|
||||||
|
db, RevB,
|
||||||
|
new[] { (DriverId, "Modbus", "dev1", "temp", "Float", false) },
|
||||||
|
Array.Empty<(string, string, string, string, string?)>(),
|
||||||
|
enabled: true);
|
||||||
|
host.Tell(new Commons.Messages.Deploy.DispatchDeployment(revB, RevB, CorrelationId.NewId()));
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(2, "the tag-set change re-spawns the child (#516 config respawn)");
|
||||||
|
factory.Created[1].LastSubscribedRefs.ShouldBe(
|
||||||
|
new[] { TempPath },
|
||||||
|
"the newly-spawned child is on the PREVIOUS revision's refs — the spawn-time set was not superseded");
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
// A Restart on top of that must hand the new child B's set, not A's — i.e. the authority really is
|
||||||
|
// repopulated WHOLE on every apply rather than accumulated.
|
||||||
|
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(3);
|
||||||
|
factory.Created[2].LastSubscribedRefs.ShouldBe(new[] { TempPath });
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The empty-set semantics are load-bearing and must not degrade into "skip the send": an EMPTY value
|
||||||
|
/// set is what tells the child to <c>Unsubscribe</c> and drop a stale handle, so it must be SENT.
|
||||||
|
/// <para>
|
||||||
|
/// The subject is a driver whose only raw tag is alarm-bearing: alarm tags are excluded from the
|
||||||
|
/// value set and carried in the alarm one, so its value half is genuinely empty while its alarm
|
||||||
|
/// half is not — and both halves ride in ONE <c>SetDesiredSubscriptions</c>. That is what makes
|
||||||
|
/// the absence assertion non-vacuous: <c>SubscribeAlarmsCount >= 1</c> proves the message
|
||||||
|
/// ARRIVED, and <c>SubscribeCount == 0</c> then proves the empty value half was honoured rather
|
||||||
|
/// than the whole send being skipped. Asserting only "it did not subscribe" would pass equally
|
||||||
|
/// against a build that sent the respawned child nothing at all — the 03/S16 defect itself.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Restart_of_a_driver_with_an_empty_value_set_still_sends_the_set()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var (host, factory) = DeployOneDriver(
|
||||||
|
db,
|
||||||
|
new[] { (DriverId, "Modbus", "dev1", "temp", "Float", false) },
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["temp"] = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""",
|
||||||
|
});
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid()));
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(2);
|
||||||
|
var respawned = factory.Created[1];
|
||||||
|
respawned.InitializeCount.ShouldBe(1, "the respawned child was never initialised");
|
||||||
|
// Positive control: the SetDesiredSubscriptions genuinely reached the respawned child.
|
||||||
|
respawned.SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
1, "the respawned child received no desired set at all — the absence below would be vacuous");
|
||||||
|
respawned.LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath });
|
||||||
|
respawned.SubscribeCount.ShouldBe(
|
||||||
|
0, "an EMPTY value set must route to Unsubscribe / no-op, never to a spurious subscribe");
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>Review finding F1 — the alarm subscription must FOLLOW a changed desired set, not latch.</b>
|
||||||
|
/// The value side converges by construction (<c>HandleSubscribeAsync</c> drops the prior subscription
|
||||||
|
/// before establishing the new one, and <c>StoreDesiredSubscriptions</c> is a full replace), but the
|
||||||
|
/// alarm side is guarded by <c>_alarmSubscriptionHandle is not null</c> in BOTH
|
||||||
|
/// <c>SubscribeDesiredAlarms</c> and <c>HandleSubscribeAlarmsAsync</c> — so once a handle exists it
|
||||||
|
/// was never re-established for the life of the connection.
|
||||||
|
/// <para>
|
||||||
|
/// That became reachable when every spawn started carrying a desired set: a tag-set edit forces a
|
||||||
|
/// respawn (raw tags fold into the merged <c>DriverConfig</c>, #516), and a fast-initialising
|
||||||
|
/// driver connects and subscribes the transient spawn-time set — the PREVIOUS revision's alarm
|
||||||
|
/// refs — before the bulk push lands. The bulk push then early-returns, latching the old set for
|
||||||
|
/// the connection's life. Drivers that FILTER on <c>sourceNodeIds</c> (OPC UA Client, AB CIP,
|
||||||
|
/// FOCAS) would silently never deliver the newly-added alarm.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_deploy_that_widens_the_alarm_set_re_subscribes_the_new_refs()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var (host, factory) = DeployOneDriver(
|
||||||
|
db,
|
||||||
|
new[] { (DriverId, "Modbus", "dev1", "alarmX", "Float", false) },
|
||||||
|
new Dictionary<string, string> { ["alarmX"] = AlarmConfig });
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].LastSubscribedAlarmRefs.ShouldBe(new[] { AlarmXPath }, ignoreOrder: true);
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
// Revision B WIDENS the alarm set: X stays, Y is added.
|
||||||
|
var revB = V3ArtifactSeeder.SeedV3Deployment(
|
||||||
|
db, RevB,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
(DriverId, "Modbus", "dev1", "alarmX", "Float", false),
|
||||||
|
(DriverId, "Modbus", "dev1", "alarmY", "Float", false),
|
||||||
|
},
|
||||||
|
Array.Empty<(string, string, string, string, string?)>(),
|
||||||
|
enabled: true,
|
||||||
|
tagConfigByTag: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["alarmX"] = AlarmConfig,
|
||||||
|
["alarmY"] = AlarmConfig,
|
||||||
|
});
|
||||||
|
host.Tell(new Commons.Messages.Deploy.DispatchDeployment(revB, RevB, CorrelationId.NewId()));
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var current = factory.Created[^1];
|
||||||
|
current.LastSubscribedAlarmRefs.ShouldBe(
|
||||||
|
new[] { AlarmXPath, AlarmYPath },
|
||||||
|
ignoreOrder: true,
|
||||||
|
"the driver is still subscribed to the PREVIOUS revision's alarm refs — the alarm " +
|
||||||
|
"subscription latched on the transient spawn-time set and never followed the new one");
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The repo's first genuine Akka SUPERVISION-restart test (03/U6).</b> Not a stop, not a stub: the
|
||||||
|
/// driver raises a malformed <c>OnDataChange</c>, the actor NREs on its own thread, and Akka's default
|
||||||
|
/// decider Restarts the child in place.
|
||||||
|
/// <para>
|
||||||
|
/// A restart is strictly worse than the operator-Restart case 03/S16 names. It recreates the actor
|
||||||
|
/// object around the SAME driver instance without going through <c>SpawnChild</c>, so nothing
|
||||||
|
/// re-sends <c>InitializeRequested</c> — while <c>PreRestart</c> has already run <c>PostStop</c>,
|
||||||
|
/// which called <c>ShutdownAsync</c> on that very driver. The child therefore sat in
|
||||||
|
/// <c>Connecting</c> forever around a shut-down driver, with its desired set gone too, and
|
||||||
|
/// silently: Akka logs the fault, nothing logs the non-recovery.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The assertions are FUNCTIONS of the new incarnation — it re-initialised, it re-subscribed the
|
||||||
|
/// deployed refs — never that it exists. <c>Created.Count == 1</c> is what distinguishes this from
|
||||||
|
/// the operator path: a restart must NOT produce a second driver object.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Supervision_restart_reinitialises_and_re_subscribes_the_child()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var (_, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].SubscribeCount.ShouldBe(1);
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
var driver = factory.Created[0];
|
||||||
|
driver.InitializeCount.ShouldBe(1);
|
||||||
|
|
||||||
|
// The crash. Subscribing is what attaches the actor's OnDataChange handler, so this only reaches the
|
||||||
|
// actor thread because the arrangement above got as far as a live subscription.
|
||||||
|
driver.FireMalformedDataChange(SpeedPath);
|
||||||
|
|
||||||
|
// FIRST: prove the crash seam actually tripped supervision, before asserting anything about the
|
||||||
|
// recovery. PreRestart runs PostStop, which calls ShutdownAsync on the driver — so a raised
|
||||||
|
// ShutdownCount is the evidence that a restart happened at all. Without this the assertions below
|
||||||
|
// would be ambiguous: "the child was not re-provisioned" and "the child never crashed" look
|
||||||
|
// identical from the outside, and a seam that silently stopped working would read as a passing
|
||||||
|
// regression test forever.
|
||||||
|
AwaitAssert(
|
||||||
|
() => driver.ShutdownCount.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
1, "the crash seam did not trip supervision — nothing below is testing a restart"),
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(
|
||||||
|
1, "a supervision restart must re-use the same driver object, not respawn the child");
|
||||||
|
driver.InitializeCount.ShouldBe(
|
||||||
|
2,
|
||||||
|
"the restarted child was never re-initialised — it is sitting in Connecting around a " +
|
||||||
|
"driver its own PostStop already shut down (03/S6, the worse half of 03/S16)");
|
||||||
|
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
2, "the restarted child never re-subscribed — its desired set died with the old instance");
|
||||||
|
driver.LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>Review finding F2 — a reprovision must be routed by INCARNATION, not just by driver id.</b>
|
||||||
|
/// <c>ReprovisionRequested</c> is delivered asynchronously, so one can still be in flight from an old
|
||||||
|
/// incarnation when <c>HandleRestartDriver</c> has already stopped that child and swapped in a new
|
||||||
|
/// one under the same <c>DriverInstanceId</c>. Routing on the id alone delivers it to the NEW child,
|
||||||
|
/// which is in <c>Connecting</c> around a healthy driver: it triggers a second, concurrent
|
||||||
|
/// <c>InitializeAsync</c> that races the very dispose-before-overwrite the #527 teardown exists to
|
||||||
|
/// serialise (<c>ModbusDriver._transport</c>, <c>MqttDriver._connection</c>).
|
||||||
|
/// <para>
|
||||||
|
/// A stale sender is simulated directly — a probe standing in for the dead incarnation — because
|
||||||
|
/// provoking the real race would be timing-dependent. The assertion is an ABSENCE, so it carries
|
||||||
|
/// a positive control: the current child is proven alive and provisioned first, and its
|
||||||
|
/// <c>InitializeCount</c> must then not move.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_reprovision_from_a_stale_incarnation_is_ignored()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
// The child must be in CONNECTING for this defect to bite: InitializeRequested is unhandled in the
|
||||||
|
// Connected behaviour, so an already-connected child would swallow the stale reprovision for the
|
||||||
|
// wrong reason and the test would pass vacuously. The gated init holds it in Connecting.
|
||||||
|
var factory = new RecordingSubscribableDriverFactory("Modbus", gateInitialize: true);
|
||||||
|
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||||
|
db, RevA,
|
||||||
|
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
|
||||||
|
Array.Empty<(string, string, string, string, string?)>(),
|
||||||
|
enabled: true);
|
||||||
|
var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
||||||
|
|
||||||
|
// Positive control: the current child is alive and has begun its one and only initialise.
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].InitializeCount.ShouldBe(1);
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
var driver = factory.Created[0];
|
||||||
|
|
||||||
|
// A ReprovisionRequested that did NOT come from the child the host currently holds.
|
||||||
|
var staleIncarnation = CreateTestProbe();
|
||||||
|
host.Tell(
|
||||||
|
new DriverInstanceActor.ReprovisionRequested(DriverId, "NullReferenceException"),
|
||||||
|
staleIncarnation.Ref);
|
||||||
|
|
||||||
|
// Round-trip a message the host must answer, so the reprovision above is provably processed before
|
||||||
|
// the absence is asserted — otherwise "nothing happened yet" would pass for "it was ignored".
|
||||||
|
var coordinator = CreateTestProbe();
|
||||||
|
host.Tell(new Commons.Messages.Fleet.GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
||||||
|
coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(PresenceBudget);
|
||||||
|
|
||||||
|
driver.InitializeCount.ShouldBe(
|
||||||
|
1,
|
||||||
|
"a reprovision from a dead incarnation reached the CURRENT child and started a second, " +
|
||||||
|
"concurrent InitializeAsync on a driver that was already connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>Review finding F3 - a crash-looping driver must not become a reconnect storm.</b> There is no
|
||||||
|
/// <c>SupervisorStrategy</c> anywhere under <c>src/Server</c>, so the default decider restarts a
|
||||||
|
/// faulting child <b>immediately and without limit</b>. That was survivable while a restart did
|
||||||
|
/// nothing; the reprovision hook now makes every one of them do
|
||||||
|
/// <c>ShutdownAsync</c> + <c>InitializeAsync</c> + <c>SubscribeAsync</c>, so a driver that faults on
|
||||||
|
/// every value would hammer its device as fast as it can crash.
|
||||||
|
/// <para>
|
||||||
|
/// The reprovision is therefore rate-limited per driver. Crucially it is <b>deferred, not
|
||||||
|
/// dropped</b> - the last request must still be honoured, or a child whose final crash fell
|
||||||
|
/// inside the window would be left un-provisioned forever, which is the very failure this whole
|
||||||
|
/// plan exists to close. Two genuine back-to-back supervision restarts: the first reprovision
|
||||||
|
/// applies immediately, the second is held and then lands.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_second_reprovision_inside_the_backoff_window_is_deferred_not_dropped()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
// Generous relative to the in-memory step time below, so "inside the window" is a decision rather
|
||||||
|
// than a race, while still far under the presence budget the deferred landing waits on.
|
||||||
|
var backoff = TimeSpan.FromSeconds(3);
|
||||||
|
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
||||||
|
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||||
|
db, RevA,
|
||||||
|
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
|
||||||
|
Array.Empty<(string, string, string, string, string?)>(),
|
||||||
|
enabled: true);
|
||||||
|
V3ArtifactSeeder.SpawnHostAndApply(
|
||||||
|
this, db, TestNode, deploymentId, RevA, factory, reprovisionBackoff: backoff);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].SubscribeCount.ShouldBe(1);
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
var driver = factory.Created[0];
|
||||||
|
|
||||||
|
// Crash 1: the first reprovision has no prior stamp, so it applies immediately.
|
||||||
|
driver.FireMalformedDataChange(SpeedPath);
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
driver.InitializeCount.ShouldBe(2);
|
||||||
|
driver.SubscribeCount.ShouldBe(2, "the re-subscribe must land before the second crash is armed");
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
// Crash 2, immediately after - well inside the window.
|
||||||
|
driver.FireMalformedDataChange(SpeedPath);
|
||||||
|
AwaitAssert(
|
||||||
|
() => driver.ShutdownCount.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
2, "the second crash seam did not trip supervision - nothing below is testing a backoff"),
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
driver.InitializeCount.ShouldBe(
|
||||||
|
2, "the second reprovision was applied immediately - the backoff is not rate-limiting anything");
|
||||||
|
|
||||||
|
// ...but it must still land once the window elapses.
|
||||||
|
AwaitAssert(
|
||||||
|
() => driver.InitializeCount.ShouldBe(
|
||||||
|
3, "the deferred reprovision was DROPPED, not deferred"),
|
||||||
|
duration: PresenceBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>_children.Keys</c> arm of the desired-set authority's union. A driver with NO authored tags
|
||||||
|
/// is absent from both the value- and alarm-ref maps, so keying the authority off those maps alone
|
||||||
|
/// would leave it with no entry at all — and "absent" must not be allowed to differ from "empty",
|
||||||
|
/// because an empty set is what tells a child to <c>Unsubscribe</c> and drop a stale handle.
|
||||||
|
/// <para>
|
||||||
|
/// Asserted the only way it is observable: the tagless child is initialised (so it is provably
|
||||||
|
/// alive and was provisioned) and never subscribes, while its tag-owning sibling in the same
|
||||||
|
/// deployment does — the sibling is the positive control proving the apply's push pass actually
|
||||||
|
/// ran, so "it did not subscribe" cannot pass for "nothing happened".
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_running_driver_with_no_authored_tags_is_handed_an_empty_set()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
||||||
|
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||||
|
db, RevA,
|
||||||
|
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
|
||||||
|
Array.Empty<(string, string, string, string, string?)>(),
|
||||||
|
enabled: true,
|
||||||
|
taglessDrivers: new[] { "DI-TAGLESS" });
|
||||||
|
V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(2);
|
||||||
|
// Positive control: the sibling that DOES have a tag subscribed, so the push pass ran.
|
||||||
|
factory.Created.Single(d => d.DriverInstanceId == DriverId)
|
||||||
|
.SubscribeCount.ShouldBe(1);
|
||||||
|
factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS")
|
||||||
|
.InitializeCount.ShouldBe(1);
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS")
|
||||||
|
.SubscribeCount.ShouldBe(
|
||||||
|
0, "a driver with no authored tags must be handed an EMPTY set, never a spurious subscribe");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The verified negative, pinned so this plan's edits to the restart paths cannot break the one that
|
||||||
|
/// was already correct. Force Reconnect (<c>ReconnectDriver</c>) Tells <c>ForceReconnect</c> to the
|
||||||
|
/// SAME child actor: the instance survives, so <c>_desiredRefs</c>/<c>_desiredAlarmRefs</c> are intact
|
||||||
|
/// and the reconnect's Connected entry re-applies them by itself. Nothing needs re-provisioning, and
|
||||||
|
/// no new driver object is created.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The budget here is deliberately wider than <see cref="RuntimeActorTestBase.PresenceBudget"/>:
|
||||||
|
/// <c>SpawnChild</c> gives the child the production <c>DefaultReconnectInterval</c> (10 s) and the
|
||||||
|
/// retry is a periodic timer, so the reconnect cannot land sooner than that. A budget is spent only
|
||||||
|
/// when the assertion fails, so widening it costs nothing on the happy path.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Force_reconnect_keeps_the_desired_set_and_re_subscribes()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) });
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1);
|
||||||
|
factory.Created[0].SubscribeCount.ShouldBe(1);
|
||||||
|
},
|
||||||
|
duration: PresenceBudget);
|
||||||
|
|
||||||
|
var driver = factory.Created[0];
|
||||||
|
host.Tell(new ReconnectDriver("c1", DriverId, "tester", Guid.NewGuid()));
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
factory.Created.Count.ShouldBe(1, "Force Reconnect must NOT respawn the child");
|
||||||
|
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
2, "the reconnected driver never re-subscribed its (surviving) desired set");
|
||||||
|
driver.LastSubscribedRefs.ShouldBe(new[] { SpeedPath });
|
||||||
|
},
|
||||||
|
duration: TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -123,7 +123,7 @@ public sealed class DriverHostCommsLossRecoveryTests : RuntimeActorTestBase
|
|||||||
(Driver: driverId, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
(Driver: driverId, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||||
},
|
},
|
||||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) },
|
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) },
|
||||||
driversEnabled: true);
|
enabled: true);
|
||||||
|
|
||||||
var (host, publish, _) = SpawnHostAndApply(
|
var (host, publish, _) = SpawnHostAndApply(
|
||||||
this, db, TestNode, deploymentId, RevA,
|
this, db, TestNode, deploymentId, RevA,
|
||||||
|
|||||||
+83
@@ -40,6 +40,10 @@ public sealed class DriverInstanceActorCommsLossWatchdogTests : RuntimeActorTest
|
|||||||
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50);
|
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50);
|
||||||
private static readonly TimeSpan FastTimeout = TimeSpan.FromMilliseconds(250);
|
private static readonly TimeSpan FastTimeout = TimeSpan.FromMilliseconds(250);
|
||||||
|
|
||||||
|
/// <summary>The threshold used by the per-connection grace pair (03/S17). Deliberately an order of
|
||||||
|
/// magnitude above <see cref="FastPoll"/> so the absence window below is a decision, not a race.</summary>
|
||||||
|
private static readonly TimeSpan Grace = TimeSpan.FromMilliseconds(1500);
|
||||||
|
|
||||||
/// <summary>Drives the actor to Connected and returns once the connect has been announced.</summary>
|
/// <summary>Drives the actor to Connected and returns once the connect has been announced.</summary>
|
||||||
private (IActorRef Actor, Akka.TestKit.TestProbe Parent) ConnectDriver(
|
private (IActorRef Actor, Akka.TestKit.TestProbe Parent) ConnectDriver(
|
||||||
HealthControlledStubDriver driver,
|
HealthControlledStubDriver driver,
|
||||||
@@ -87,6 +91,14 @@ public sealed class DriverInstanceActorCommsLossWatchdogTests : RuntimeActorTest
|
|||||||
/// <c>ModbusDriver.ReadAsync</c> leaves latched whenever the bad tag is read last — but it is still
|
/// <c>ModbusDriver.ReadAsync</c> leaves latched whenever the bad tag is read last — but it is still
|
||||||
/// reading successfully, so <c>LastSuccessfulRead</c> keeps advancing. It must be left alone.
|
/// reading successfully, so <c>LastSuccessfulRead</c> keeps advancing. It must be left alone.
|
||||||
/// A state-only watchdog would tear this driver down on every threshold, forever.
|
/// A state-only watchdog would tear this driver down on every threshold, forever.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Falsification partner (03/S17):</b>
|
||||||
|
/// <see cref="A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again"/>.
|
||||||
|
/// The silence baseline is <c>max(LastSuccessfulRead, _connectedSinceUtc)</c>, and each test kills
|
||||||
|
/// one term of that max — this one goes red if the <c>LastSuccessfulRead</c> term is dropped (its
|
||||||
|
/// connect ages far past the threshold while its reads keep succeeding), that one goes red if the
|
||||||
|
/// <c>_connectedSinceUtc</c> term is dropped (i.e. if the original <c>??</c> is restored).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_degraded_driver_that_is_still_reading_successfully_is_left_alone()
|
public void A_degraded_driver_that_is_still_reading_successfully_is_left_alone()
|
||||||
@@ -258,6 +270,77 @@ public sealed class DriverInstanceActorCommsLossWatchdogTests : RuntimeActorTest
|
|||||||
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 03/S17 — <b>the grace is per connection.</b> Drivers deliberately <em>preserve</em>
|
||||||
|
/// <see cref="DriverHealth.LastSuccessfulRead"/> across Degraded/Faulted health writes (that
|
||||||
|
/// preservation is the #527 design), so for a device that accepts a TCP connection but never serves
|
||||||
|
/// data the stamp stays frozen from before the outage — already older than the threshold. Measuring
|
||||||
|
/// silence from that stamp alone (<c>??</c>) makes the <b>first</b> health tick of every new
|
||||||
|
/// connection re-trip, so the reconnect cycle runs at health-tick cadence (~30 s in production)
|
||||||
|
/// instead of the designed grace. Measuring from <c>max(LastSuccessfulRead, _connectedSinceUtc)</c>
|
||||||
|
/// gives each connection its own full window, which is exactly what the <c>_connectedSinceUtc</c>
|
||||||
|
/// re-stamp on every Connected entry already promises in its comment.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Falsification partner:</b>
|
||||||
|
/// <see cref="A_degraded_driver_that_is_still_reading_successfully_is_left_alone"/> — see its
|
||||||
|
/// remarks for how the pair covers both terms of the max.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The absence window carries the house positive control: a proven number of health-poll ticks
|
||||||
|
/// processed <em>after</em> the reconnect, so "the watchdog did not re-trip" cannot pass because
|
||||||
|
/// the actor stopped ticking. It is also deliberately far shorter than <see cref="Grace"/>, so
|
||||||
|
/// it asserts a delay, not a disablement — <see cref="A_half_broken_device_is_still_declared_lost_once_the_grace_elapses"/>
|
||||||
|
/// is the anti-vacuity half.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again()
|
||||||
|
{
|
||||||
|
var driver = new HealthControlledStubDriver
|
||||||
|
{
|
||||||
|
// The half-broken device: the socket connects, every read fails, and the last successful read is
|
||||||
|
// frozen well before the outage — preserved across the Degraded writes, as every driver does.
|
||||||
|
Health = new DriverHealth(DriverState.Degraded, DateTime.UtcNow.AddMinutes(-5), "reads time out"),
|
||||||
|
};
|
||||||
|
var (_, parent) = ConnectDriver(driver, commsLossTimeout: Grace, reconnectInterval: TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
// The first trip is correct and expected — the device really has been silent for five minutes.
|
||||||
|
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget)
|
||||||
|
.Connected.ShouldBeFalse();
|
||||||
|
// The retry re-initialises successfully (the socket still accepts), so a NEW connection starts here.
|
||||||
|
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget)
|
||||||
|
.Connected.ShouldBeTrue();
|
||||||
|
|
||||||
|
var ticksAtReconnect = driver.HealthReadCount;
|
||||||
|
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||||
|
(driver.HealthReadCount - ticksAtReconnect).ShouldBeGreaterThanOrEqualTo(
|
||||||
|
4,
|
||||||
|
"health-poll ticks did not run after the reconnect — the absence assertion above would be vacuous");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The anti-vacuity half of the 03/S17 pair: the per-connection grace <b>delays</b> the watchdog, it
|
||||||
|
/// does not disable it. The same half-broken device is still declared lost once the grace elapses —
|
||||||
|
/// without this, a "fix" that simply stopped evaluating the watchdog after the first connection would
|
||||||
|
/// pass <see cref="A_reconnected_driver_gets_a_full_grace_window_before_the_watchdog_trips_again"/>
|
||||||
|
/// and look correct.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_half_broken_device_is_still_declared_lost_once_the_grace_elapses()
|
||||||
|
{
|
||||||
|
var driver = new HealthControlledStubDriver
|
||||||
|
{
|
||||||
|
Health = new DriverHealth(DriverState.Degraded, DateTime.UtcNow.AddMinutes(-5), "reads time out"),
|
||||||
|
};
|
||||||
|
var (_, parent) = ConnectDriver(driver, commsLossTimeout: Grace, reconnectInterval: TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
|
||||||
|
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeTrue();
|
||||||
|
|
||||||
|
// The second connection serves no data either, so once ITS grace elapses the watchdog trips again.
|
||||||
|
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -188,3 +188,215 @@ internal sealed class StubHandle : ISubscriptionHandle
|
|||||||
/// <summary>Gets the diagnostic ID of the subscription.</summary>
|
/// <summary>Gets the diagnostic ID of the subscription.</summary>
|
||||||
public string DiagnosticId => "stub-sub";
|
public string DiagnosticId => "stub-sub";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A per-instance recording driver for the restart / re-subscribe suite (03/S16, U6): unlike
|
||||||
|
/// <see cref="SubscribableStubDriver"/> it carries the <b>spec's</b> <see cref="DriverInstanceId"/>
|
||||||
|
/// (<see cref="StubDriver"/> hard-codes a constant on a non-virtual member), and it counts and records the
|
||||||
|
/// alarm subscription alongside the value one — the two travel in a single
|
||||||
|
/// <c>SetDesiredSubscriptions</c> but through two different child code paths, so both need observing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The falsification bar these counters exist for:</b> a restart test must assert a FUNCTION the new
|
||||||
|
/// incarnation performs — it subscribed, it re-initialised — never that the incarnation exists.
|
||||||
|
/// Existence is what
|
||||||
|
/// <c>DriverHostActorReconcileTests.RestartDriver_respawns_the_child_without_an_actor_name_collision</c>
|
||||||
|
/// asserts, and that test passes against the very defect 03/S16 names.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b><see cref="FireMalformedDataChange"/> is the supervision crash seam</b> — see
|
||||||
|
/// <c>DriverHostActorRestartTests</c>'s file remarks for why the obvious alternatives do not work.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RecordingSubscribableDriver : IDriver, ISubscribable, IAlarmSource
|
||||||
|
{
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private readonly StubHandle _handle = new();
|
||||||
|
private readonly RecordingAlarmHandle _alarmHandle = new();
|
||||||
|
private int _initializeCount;
|
||||||
|
private int _shutdownCount;
|
||||||
|
private int _subscribeCount;
|
||||||
|
private int _subscribeAlarmsCount;
|
||||||
|
private IReadOnlyList<string>? _lastSubscribedRefs;
|
||||||
|
private IReadOnlyList<string>? _lastSubscribedAlarmRefs;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new recording driver stamped with the spec's identity.</summary>
|
||||||
|
/// <param name="driverInstanceId">The driver instance id from the spec that created it.</param>
|
||||||
|
/// <param name="driverType">The driver type from the spec that created it.</param>
|
||||||
|
public RecordingSubscribableDriver(string driverInstanceId, string driverType)
|
||||||
|
{
|
||||||
|
DriverInstanceId = driverInstanceId;
|
||||||
|
DriverType = driverType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverInstanceId { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType { get; }
|
||||||
|
|
||||||
|
/// <summary>Number of <see cref="InitializeAsync"/> calls. A supervision restart re-uses the SAME driver
|
||||||
|
/// object (the Props closure captured it), so this going 1 to 2 is how the reprovision is observed.</summary>
|
||||||
|
public int InitializeCount => Volatile.Read(ref _initializeCount);
|
||||||
|
|
||||||
|
/// <summary>Number of <see cref="ShutdownAsync"/> calls — a restart's <c>PostStop</c> raises it.</summary>
|
||||||
|
public int ShutdownCount => Volatile.Read(ref _shutdownCount);
|
||||||
|
|
||||||
|
/// <summary>Number of <see cref="SubscribeAsync"/> calls.</summary>
|
||||||
|
public int SubscribeCount => Volatile.Read(ref _subscribeCount);
|
||||||
|
|
||||||
|
/// <summary>Number of <see cref="SubscribeAlarmsAsync"/> calls.</summary>
|
||||||
|
public int SubscribeAlarmsCount => Volatile.Read(ref _subscribeAlarmsCount);
|
||||||
|
|
||||||
|
/// <summary>The refs passed to the most recent <see cref="SubscribeAsync"/>, or null if never called.</summary>
|
||||||
|
public IReadOnlyList<string>? LastSubscribedRefs
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _lastSubscribedRefs; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The refs passed to the most recent <see cref="SubscribeAlarmsAsync"/>, or null if never called.</summary>
|
||||||
|
public IReadOnlyList<string>? LastSubscribedAlarmRefs
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _lastSubscribedAlarmRefs; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, <see cref="InitializeAsync"/> awaits it instead of completing inline — so the actor
|
||||||
|
/// stays in <c>Connecting</c> for as long as the test wants. Needed because several defects only
|
||||||
|
/// bite a child that has NOT yet connected (<c>InitializeRequested</c> is unhandled in
|
||||||
|
/// <c>Connected</c>, so a test built on an already-connected child passes vacuously).
|
||||||
|
/// </summary>
|
||||||
|
public TaskCompletionSource? InitializeGate { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _initializeCount);
|
||||||
|
if (InitializeGate is { } gate) await gate.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _shutdownCount);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _subscribeCount);
|
||||||
|
lock (_gate) _lastSubscribedRefs = fullReferences.ToArray();
|
||||||
|
return Task.FromResult<ISubscriptionHandle>(_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||||
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _subscribeAlarmsCount);
|
||||||
|
lock (_gate) _lastSubscribedAlarmRefs = sourceNodeIds.ToArray();
|
||||||
|
return Task.FromResult<IAlarmSubscriptionHandle>(_alarmHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AcknowledgeAsync(
|
||||||
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>Fires a well-formed data change (the ordinary production path).</summary>
|
||||||
|
/// <param name="fullRef">The reference whose value changed.</param>
|
||||||
|
/// <param name="value">The new value.</param>
|
||||||
|
public void FireDataChange(string fullRef, object? value) =>
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(
|
||||||
|
_handle, fullRef, new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises <see cref="OnDataChange"/> with a NULL <c>Snapshot</c> — the supervision crash seam.
|
||||||
|
/// <c>DataChangeEventArgs</c> is a positional record with no null validation, and the actor's
|
||||||
|
/// <c>OnDataChangeForward</c> dereferences <c>msg.Snapshot</c> unguarded ON THE ACTOR THREAD, so this
|
||||||
|
/// produces a genuine unhandled exception inside the child, which Akka's default decider turns into a
|
||||||
|
/// Restart. The fault itself is arbitrary; what is under test is the RECOVERY.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fullRef">The reference to name in the malformed event.</param>
|
||||||
|
public void FireMalformedDataChange(string fullRef) =>
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, null!));
|
||||||
|
|
||||||
|
/// <summary>Raises <see cref="OnAlarmEvent"/>; present so the event is not merely written-to-never-read.</summary>
|
||||||
|
/// <param name="conditionId">The condition id to raise.</param>
|
||||||
|
public void FireAlarm(string conditionId) =>
|
||||||
|
OnAlarmEvent?.Invoke(this, new AlarmEventArgs(
|
||||||
|
_alarmHandle, conditionId, conditionId, "AlarmCondition", "test", AlarmSeverity.High, DateTime.UtcNow));
|
||||||
|
|
||||||
|
private sealed class RecordingAlarmHandle : IAlarmSubscriptionHandle
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DiagnosticId => "recording-alarm-sub";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="IDriverFactory"/> that hands out <see cref="RecordingSubscribableDriver"/>s and KEEPS
|
||||||
|
/// every one it created, in creation order. The list — not a single "the driver" reference — is the point:
|
||||||
|
/// an operator Restart tears the child down and spawns a new one around a NEW driver object, so the
|
||||||
|
/// assertion subject is <c>Created[1]</c>, while a SUPERVISION restart re-uses <c>Created[0]</c> (the Props
|
||||||
|
/// closure captured it) and part of the assertion is that <c>Created.Count</c> stayed 1.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RecordingSubscribableDriverFactory : IDriverFactory
|
||||||
|
{
|
||||||
|
private readonly string _supportedType;
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private readonly List<RecordingSubscribableDriver> _created = new();
|
||||||
|
|
||||||
|
private readonly bool _gateInitialize;
|
||||||
|
|
||||||
|
/// <summary>Initializes a factory that materialises exactly one driver type.</summary>
|
||||||
|
/// <param name="supportedType">The driver type this factory answers for.</param>
|
||||||
|
/// <param name="gateInitialize">When true, every created driver gets an un-completed
|
||||||
|
/// <see cref="RecordingSubscribableDriver.InitializeGate"/>, so its child actor stays in
|
||||||
|
/// <c>Connecting</c> until the test completes it.</param>
|
||||||
|
public RecordingSubscribableDriverFactory(string supportedType, bool gateInitialize = false)
|
||||||
|
{
|
||||||
|
_supportedType = supportedType;
|
||||||
|
_gateInitialize = gateInitialize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every driver this factory created, in creation order.</summary>
|
||||||
|
public IReadOnlyList<RecordingSubscribableDriver> Created
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _created.ToArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
|
||||||
|
var driver = new RecordingSubscribableDriver(driverInstanceId, driverType);
|
||||||
|
if (_gateInitialize)
|
||||||
|
driver.InitializeGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
lock (_gate) _created.Add(driver);
|
||||||
|
return driver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,20 +37,35 @@ public static class V3ArtifactSeeder
|
|||||||
/// <param name="rev">The revision hash the deployment is sealed under.</param>
|
/// <param name="rev">The revision hash the deployment is sealed under.</param>
|
||||||
/// <param name="rawTags">The raw tags to materialise, one per (driver, device, tag).</param>
|
/// <param name="rawTags">The raw tags to materialise, one per (driver, device, tag).</param>
|
||||||
/// <param name="unsRefs">UNS references projecting a raw tag (by <c>BackingTag</c>) into an equipment.</param>
|
/// <param name="unsRefs">UNS references projecting a raw tag (by <c>BackingTag</c>) into an equipment.</param>
|
||||||
/// <param name="driversEnabled">Whether the seeded DriverInstance rows are Enabled — i.e. whether the
|
/// <param name="enabled">Whether the seeded <c>DriverInstances</c> are Enabled — i.e. whether applying this
|
||||||
/// host SPAWNS a child around them. False (the default) keeps the routing suites child-free; a suite
|
/// artifact actually SPAWNS driver children. Defaults to <c>false</c>, the shape every pre-existing caller
|
||||||
/// that needs a real <c>DriverInstanceActor</c> sets it and supplies a driver factory.</param>
|
/// relies on: the value-routing suites inject values straight into the host and want no children at all.
|
||||||
|
/// The restart/re-subscribe suite (03/S16) passes <c>true</c>, because its whole subject is what a spawned
|
||||||
|
/// child is handed.</param>
|
||||||
|
/// <param name="tagConfigByTag">Optional per-tag <c>TagConfig</c> JSON, keyed by tag name; tags not named
|
||||||
|
/// here keep the default <c>"{}"</c>. The only way to seed an <b>alarm-bearing</b> raw tag (the alarm intent
|
||||||
|
/// is parsed out of <c>TagConfig.alarm</c> by <c>TagConfigIntent</c>), which the alarm-ref half of the
|
||||||
|
/// restart suite needs — an alarm-bearing raw tag is excluded from the VALUE subscription set and carried
|
||||||
|
/// in the alarm one instead, so the two travel through different child code paths.</param>
|
||||||
|
/// <param name="taglessDrivers">Extra <c>DriverInstance</c> ids carrying NO devices and NO tags. The
|
||||||
|
/// only way to express a driver that is a running child yet absent from both the value- and alarm-ref
|
||||||
|
/// maps — the <c>_children.Keys</c> arm of <c>ApplyDesiredSubscriptions</c>'s union, which must still
|
||||||
|
/// hand that child an explicit EMPTY set rather than skipping it.</param>
|
||||||
/// <returns>The seeded deployment's id.</returns>
|
/// <returns>The seeded deployment's id.</returns>
|
||||||
public static DeploymentId SeedV3Deployment(
|
public static DeploymentId SeedV3Deployment(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||||
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
||||||
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs,
|
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs,
|
||||||
bool driversEnabled = false)
|
bool enabled = false,
|
||||||
|
IReadOnlyDictionary<string, string>? tagConfigByTag = null,
|
||||||
|
string[]? taglessDrivers = null)
|
||||||
{
|
{
|
||||||
var drivers = rawTags
|
var drivers = rawTags
|
||||||
.Select(t => (t.Driver, t.DriverName))
|
.Select(t => (t.Driver, t.DriverName))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = driversEnabled })
|
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
|
||||||
|
.Concat((taglessDrivers ?? Array.Empty<string>())
|
||||||
|
.Select(id => new { DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var devices = rawTags
|
var devices = rawTags
|
||||||
.Select(t => (t.Driver, t.Device))
|
.Select(t => (t.Driver, t.Device))
|
||||||
@@ -65,7 +80,7 @@ public static class V3ArtifactSeeder
|
|||||||
Name = t.Tag,
|
Name = t.Tag,
|
||||||
DataType = t.DataType,
|
DataType = t.DataType,
|
||||||
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
|
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
|
||||||
TagConfig = "{}",
|
TagConfig = tagConfigByTag is not null && tagConfigByTag.TryGetValue(t.Tag, out var tc) ? tc : "{}",
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
|
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
|
||||||
@@ -119,21 +134,26 @@ public static class V3ArtifactSeeder
|
|||||||
/// <param name="localNode">The host's local node id.</param>
|
/// <param name="localNode">The host's local node id.</param>
|
||||||
/// <param name="deploymentId">The deployment to dispatch.</param>
|
/// <param name="deploymentId">The deployment to dispatch.</param>
|
||||||
/// <param name="rev">The revision hash the deployment was sealed under.</param>
|
/// <param name="rev">The revision hash the deployment was sealed under.</param>
|
||||||
|
/// <param name="driverFactory">Optional driver factory forwarded to <c>DriverHostActor.Props</c>. Null (the
|
||||||
|
/// default, preserving every existing call site) leaves the host on its own default, which materialises no
|
||||||
|
/// real drivers. Supply one — with <c>enabled: true</c> on the seed — to actually spawn driver children.</param>
|
||||||
|
/// <param name="reprovisionBackoff">Optional per-driver supervision-reprovision rate limit forwarded to
|
||||||
|
/// <c>DriverHostActor.Props</c>; null keeps the production default. Exists so a test can observe the
|
||||||
|
/// deferral without waiting the production window.</param>
|
||||||
/// <param name="commsRecoveryAuditDelay">Optional 03/S15 audit delay — how long after a reconnect the
|
/// <param name="commsRecoveryAuditDelay">Optional 03/S15 audit delay — how long after a reconnect the
|
||||||
/// host checks for refs still latched <c>BadNoCommunication</c>. Null keeps the production default,
|
/// host checks for refs still latched <c>BadNoCommunication</c>. Null keeps the production default,
|
||||||
/// which is far longer than any test's lifetime.</param>
|
/// which is far longer than any test's lifetime.</param>
|
||||||
/// <param name="driverFactory">Optional factory the host builds its driver children from. Null (the
|
|
||||||
/// default) leaves the null factory, which — with <c>driversEnabled: false</c> — means no child is
|
|
||||||
/// spawned at all.</param>
|
|
||||||
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
|
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
|
||||||
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
|
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
|
||||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||||
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
|
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
|
||||||
TimeSpan? commsRecoveryAuditDelay = null,
|
IDriverFactory? driverFactory = null,
|
||||||
IDriverFactory? driverFactory = null)
|
TimeSpan? reprovisionBackoff = null,
|
||||||
|
TimeSpan? commsRecoveryAuditDelay = null)
|
||||||
{
|
{
|
||||||
var publish = kit.CreateTestProbe();
|
var publish = kit.CreateTestProbe();
|
||||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, commsRecoveryAuditDelay, driverFactory);
|
var (actor, mux, coordinator) =
|
||||||
|
Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff, commsRecoveryAuditDelay);
|
||||||
|
|
||||||
Dispatch(actor, coordinator, deploymentId, rev);
|
Dispatch(actor, coordinator, deploymentId, rev);
|
||||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
|
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
|
||||||
@@ -153,15 +173,15 @@ public static class V3ArtifactSeeder
|
|||||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||||
NodeId localNode, DeploymentId deploymentId, RevisionHash rev)
|
NodeId localNode, DeploymentId deploymentId, RevisionHash rev)
|
||||||
{
|
{
|
||||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null);
|
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null, driverFactory: null, reprovisionBackoff: null);
|
||||||
Dispatch(actor, coordinator, deploymentId, rev);
|
Dispatch(actor, coordinator, deploymentId, rev);
|
||||||
return (actor, mux);
|
return (actor, mux);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
|
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
|
||||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
|
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
|
||||||
IActorRef? opcUaPublishActor, TimeSpan? commsRecoveryAuditDelay = null,
|
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff,
|
||||||
IDriverFactory? driverFactory = null)
|
TimeSpan? commsRecoveryAuditDelay = null)
|
||||||
{
|
{
|
||||||
var coordinator = kit.CreateTestProbe();
|
var coordinator = kit.CreateTestProbe();
|
||||||
var mux = kit.CreateTestProbe();
|
var mux = kit.CreateTestProbe();
|
||||||
@@ -175,6 +195,7 @@ public static class V3ArtifactSeeder
|
|||||||
opcUaPublishActor: opcUaPublishActor,
|
opcUaPublishActor: opcUaPublishActor,
|
||||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||||
virtualTagHostOverride: vtHost.Ref,
|
virtualTagHostOverride: vtHost.Ref,
|
||||||
|
reprovisionBackoff: reprovisionBackoff,
|
||||||
commsRecoveryAuditDelay: commsRecoveryAuditDelay));
|
commsRecoveryAuditDelay: commsRecoveryAuditDelay));
|
||||||
|
|
||||||
return (actor, mux, coordinator);
|
return (actor, mux, coordinator);
|
||||||
|
|||||||
Reference in New Issue
Block a user