Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ccc237cb6 | |||
| b3d1a26f38 | |||
| 3336ec08c7 | |||
| e27c19c49d | |||
| f347762350 | |||
| 2cae4c8f01 | |||
| 043e237dba | |||
| 8c5e2be92e | |||
| 6dda0549e2 | |||
| db751d12a5 | |||
| f6a3c31b60 | |||
| e08b6b0e69 | |||
| 50426d4790 | |||
| 7339a4af07 | |||
| 872cf7e37a | |||
| f1534920de | |||
| 9bb237b794 | |||
| 1424a21419 | |||
| ce383df39a | |||
| a0be76b5f0 | |||
| 73d8439412 | |||
| 8843418c54 | |||
| 772d3a5f34 | |||
| ec6598ceae |
@@ -132,6 +132,10 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.2.1" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.1" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.1" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.2.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
<package pattern="ZB.MOM.WW.Theme" />
|
<package pattern="ZB.MOM.WW.Theme" />
|
||||||
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
||||||
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
||||||
|
<package pattern="ZB.MOM.WW.Secrets" />
|
||||||
|
<package pattern="ZB.MOM.WW.Secrets.*" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
</packageSourceMapping>
|
</packageSourceMapping>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -51,6 +51,136 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
|||||||
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||||
|
|
||||||
|
## Condition event identity fields (what a client reads on the wire)
|
||||||
|
|
||||||
|
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
|
||||||
|
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
|
||||||
|
does **not** synthesize them on this path (`Create` builds the children from the type definition
|
||||||
|
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
|
||||||
|
are set explicitly. Leaving them unset shipped them as **null** on every event — see issues #473
|
||||||
|
(the `BaseEventType` trio) and #475 (the `ConditionType` classification pair).
|
||||||
|
|
||||||
|
| Field | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
|
||||||
|
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
|
||||||
|
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
|
||||||
|
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
|
||||||
|
| `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
|
||||||
|
| `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) |
|
||||||
|
| `Quality` | the condition's **source-data quality** — native tracks the source's connectivity (`Good` / `Bad`); scripted takes the worst of its input tags' qualities (#478) | A pure annotation; never alters Active/Acked/Retain. Unset shipped the accidentally-Good default (#477) — see below |
|
||||||
|
|
||||||
|
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
|
||||||
|
classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on.
|
||||||
|
`BaseConditionClassType` is the honest, spec-conformant report of *"this server does not model
|
||||||
|
condition classes"* — it fixes the real defect (a null that breaks conformant clients) without
|
||||||
|
asserting a classification we cannot back. `ProcessConditionClassType` — the SDK sample's pick —
|
||||||
|
was rejected deliberately: it would be *actively wrong* for a Galaxy alarm whose upstream category is
|
||||||
|
Safety or Diagnostics, trading a detectable null for an undetectable lie. Real per-alarm
|
||||||
|
classification is a separate future feature: it needs the driver's alarm category, which today lives
|
||||||
|
only on the runtime `AlarmEventArgs` transition, carried to the deploy-time authored composition that
|
||||||
|
`MaterialiseAlarmCondition` sees. Until then the `IAlarmSource` doc comment claiming the category
|
||||||
|
"maps to `ConditionClassName` downstream" describes an intent, not the implementation.
|
||||||
|
|
||||||
|
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
|
||||||
|
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
|
||||||
|
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
|
||||||
|
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
|
||||||
|
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
|
||||||
|
|
||||||
|
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
|
||||||
|
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
|
||||||
|
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
|
||||||
|
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
|
||||||
|
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
|
||||||
|
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
|
||||||
|
|
||||||
|
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three `BaseEventType`
|
||||||
|
fields arrive populated on a real subscription using the standard `[EventType, SourceNode,
|
||||||
|
SourceName, Time, Message, Severity]` select clause, and — in a second test with its own clause —
|
||||||
|
that `ConditionClassId` / `ConditionClassName` do too. The two class fields are declared on
|
||||||
|
`ConditionType`, **not** `BaseEventType`, so a client must select them against that type.
|
||||||
|
`NodeManagerAlarmSourceFieldsTests` guards the node itself across both realms.
|
||||||
|
|
||||||
|
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
|
||||||
|
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
|
||||||
|
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
|
||||||
|
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
|
||||||
|
> alarm-history writer stamps that field with the **EquipmentPath**
|
||||||
|
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
|
||||||
|
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
|
||||||
|
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
|
||||||
|
> live-path fix above does not change the history path.)
|
||||||
|
|
||||||
|
### Condition source-data Quality (#477)
|
||||||
|
|
||||||
|
`ConditionType.Quality` reports the quality of the condition's source data. It was never assigned, and
|
||||||
|
because `StatusCodes.Good == 0x00000000` an unassigned `StatusCode` **is** `Good` — so every condition
|
||||||
|
reported `Good` unconditionally (a wrong *value*, not a null like #473/#475). A native alarm whose device
|
||||||
|
went offline still read `Good`, so an operator could not tell *"genuinely inactive"* from *"we have lost
|
||||||
|
contact and do not know"*.
|
||||||
|
|
||||||
|
**How it is driven now (native alarms).** An alarm-bearing raw tag materializes a condition with **no
|
||||||
|
sibling value variable**, so the value/quality path (`WriteValue`) never touches it, and a comms-lost
|
||||||
|
driver emits **no alarm transitions** (the feed goes silent). The quality therefore comes from the
|
||||||
|
**driver's connectivity**, out of band from alarm transitions:
|
||||||
|
|
||||||
|
- `DriverInstanceActor` Tells its host `ConnectivityChanged(driverInstanceId, connected)` on every
|
||||||
|
transition into `Connected` (`true`) / `Reconnecting` (`false`).
|
||||||
|
- `DriverHostActor.OnDriverConnectivityChanged` fans that out to **every** native condition the driver
|
||||||
|
owns as an `OpcUaPublishActor.AlarmQualityUpdate` (`Good` on connect, `Bad` on disconnect).
|
||||||
|
- `OtOpcUaNodeManager.WriteAlarmQuality` sets **only** the condition's `Quality` and fires a Part 9
|
||||||
|
event **only on a quality-bucket change** — it never touches Active/Acked/Severity/Retain (an active
|
||||||
|
alarm that loses comms stays active). This is a dedicated path, *not* a full-snapshot re-projection, so
|
||||||
|
it cannot clobber a condition's severity/message and works for a condition that never fired a transition.
|
||||||
|
- A freshly materialized native condition starts `BadWaitingForInitialData` (the "no driver data yet"
|
||||||
|
convention value variables use); the first `Connected` confirms it `Good`.
|
||||||
|
- The connectivity annotation is **ungated by redundancy role** (a Secondary keeps its condition quality
|
||||||
|
warm for failover) and publishes **no `/alerts` row** — driver comms health already has its own status
|
||||||
|
surface (`IDriverHealthPublisher`); a row per condition would be alarm-fatigue.
|
||||||
|
|
||||||
|
**Scripted alarms (Layer 3, #478).** A scripted condition's state is computed from one or more input tags,
|
||||||
|
so its `Quality` is the **worst** quality across those inputs at evaluation time ("can I trust this
|
||||||
|
condition's state?") — mirroring the native OT semantic:
|
||||||
|
|
||||||
|
- The mux now forwards each input's source quality (`DependencyValueChanged.Quality`), and the scripted host
|
||||||
|
pushes it into the engine's read cache (previously every mux value was treated as `Good`).
|
||||||
|
- The `ScriptedAlarmEngine` computes the worst input quality each evaluation. A **real transition** carries
|
||||||
|
it on the emitted event → `ScriptedAlarmHostActor.ToSnapshot` projects it (so a transition fired while an
|
||||||
|
input is `Uncertain` does not clobber quality back to `Good`).
|
||||||
|
- A **Bad input freezes the condition** (`AreInputsReady` holds its state — no transition), exactly like a
|
||||||
|
comms-lost native driver. So a quality-bucket change with no transition is emitted as
|
||||||
|
`EmissionKind.QualityChanged` and routed to the **same** dedicated `AlarmQualityUpdate → WriteAlarmQuality`
|
||||||
|
node path native uses (quality only, one Part 9 event on a bucket change, **no `/alerts` row**, no
|
||||||
|
historian write). `ScriptedAlarmSource` skips `QualityChanged` so it never fabricates a phantom
|
||||||
|
`IAlarmSource` event.
|
||||||
|
- An input that has **not been published yet** (cold start) is *not* a quality signal (that is the readiness
|
||||||
|
guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first
|
||||||
|
actually-`Bad` published value flips the bucket and annotates.
|
||||||
|
|
||||||
|
**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a
|
||||||
|
data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's
|
||||||
|
per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device
|
||||||
|
goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see
|
||||||
|
`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
|
||||||
|
condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native
|
||||||
|
`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime
|
||||||
|
`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked
|
||||||
|
as the Layer-4 follow-up (#481).
|
||||||
|
|
||||||
|
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
|
||||||
|
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing),
|
||||||
|
`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`,
|
||||||
|
`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and
|
||||||
|
`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`,
|
||||||
|
`Transition_snapshot_carries_worst_input_quality`).
|
||||||
|
|
||||||
|
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests.Condition_event_Quality_tracks_source_connectivity_on_the_wire`
|
||||||
|
subscribes with a `[Quality, Message]` clause (Quality is declared on `ConditionType`) and asserts a
|
||||||
|
healthy source reports `Good`, a comms-lost source reports non-`Good`, and recovery returns to `Good` — on
|
||||||
|
a real client subscription. `NodeManagerAlarmSourceFieldsTests` guards the node itself + the no-clobber /
|
||||||
|
unknown-node-no-op invariants.
|
||||||
|
|
||||||
## Galaxy driver path (driver-native)
|
## Galaxy driver path (driver-native)
|
||||||
|
|
||||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Secrets: Clustered Master-Key Posture (All Roles)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
|
||||||
|
pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||||
|
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) that runs at **every** OtOpcUa node
|
||||||
|
boot, before the host is built. It reads rows from an envelope-encrypted SQLite
|
||||||
|
store (`Secrets:SqlitePath`) unwrapped with a key-encryption key (KEK) sourced per
|
||||||
|
`Secrets:MasterKey:Source`. The runtime `ISecretResolver` (`AddZbSecrets`) is also
|
||||||
|
registered unconditionally, independent of node role.
|
||||||
|
|
||||||
|
OtOpcUa is Akka.NET-clustered (`builder.Services.AddAkka("otopcua", (ab, sp) => { ... })`
|
||||||
|
in `Program.cs`), with roles parsed by `RoleParser` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs`):
|
||||||
|
`admin`, `driver`, `dev`. A node can carry any combination of these roles (e.g. a
|
||||||
|
fused admin+driver node, or a driver-only node). This runbook covers what a
|
||||||
|
production deployment needs so that secret resolution behaves identically on
|
||||||
|
**every node regardless of role** — not just admin nodes. That matters here more
|
||||||
|
than it might elsewhere: the pre-host expander and the runtime resolver both run
|
||||||
|
unconditionally on driver-only nodes too, and driver-only nodes are the ones that
|
||||||
|
will resolve Layer-B `DriverConfig` secret references (coming in Slice 2) at
|
||||||
|
runtime, not just at boot. It does **not** change any code; it is an
|
||||||
|
operations/deployment posture, delivered out-of-band from the committed config.
|
||||||
|
|
||||||
|
## The two hard requirements
|
||||||
|
|
||||||
|
For the pre-host expander (and the runtime resolver) to resolve the same
|
||||||
|
plaintext secret on every node, no matter its role:
|
||||||
|
|
||||||
|
1. **Identical KEK on every node.** All nodes — admin, driver, and any fused
|
||||||
|
combination — must unwrap the store with the exact same master key. A
|
||||||
|
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
|
||||||
|
decrypt every *other* node's ciphertext rows to garbage.
|
||||||
|
2. **Identical store rows on every node.** All nodes must read the same SQLite
|
||||||
|
database (same file, or a replicated/shared copy with the same rows) — not
|
||||||
|
independently-seeded stores that happen to share a KEK.
|
||||||
|
|
||||||
|
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
|
||||||
|
— there is no built-in cross-node replication today. Meeting both requirements in
|
||||||
|
production is a deployment concern, covered below.
|
||||||
|
|
||||||
|
## Recommended interim posture (G-5)
|
||||||
|
|
||||||
|
Until real replication exists (G-7, below), the recommended production posture is:
|
||||||
|
|
||||||
|
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
|
||||||
|
key file that is identical on every node of every role** — a base64-encoded
|
||||||
|
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
|
||||||
|
to each node's filesystem/secret-mount by the deployment tooling, and **never
|
||||||
|
committed** to the repo. Treat it with the same discipline as any other
|
||||||
|
production secret (restrictive file ACLs, no logging, rotated via a future
|
||||||
|
KEK-rotation runbook — not yet built).
|
||||||
|
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
|
||||||
|
that every node mounts (admin, driver, and dev/fused alike), so every node's
|
||||||
|
migrator opens and reads the same rows at boot.
|
||||||
|
|
||||||
|
Writes to the store are rare and human-driven — an operator using the
|
||||||
|
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
|
||||||
|
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
|
||||||
|
regardless of role. The access pattern is read-mostly / effectively
|
||||||
|
single-writer, which is what makes a shared SQLite volume viable as an interim
|
||||||
|
posture (see caveat below).
|
||||||
|
|
||||||
|
## How it's delivered (do NOT commit these values)
|
||||||
|
|
||||||
|
The File-KEK + shared-store posture is supplied per-node at deployment time —
|
||||||
|
**never** by editing the committed `appsettings.json` or the role-overlay files
|
||||||
|
(`appsettings.admin.json`, `appsettings.driver.json`, `appsettings.admin-driver.json`).
|
||||||
|
Those committed overlays are also consumed by local dev and the
|
||||||
|
`TwoNodeClusterHarness` integration tests, so hardcoding a `Source=File` path
|
||||||
|
into them would break every dev/test/CI boot. Two acceptable delivery
|
||||||
|
mechanisms instead:
|
||||||
|
|
||||||
|
**Option A — environment variable overrides** (Windows Service / NSSM env block,
|
||||||
|
container `env_file`, etc.), applied identically on every node regardless of role:
|
||||||
|
|
||||||
|
```
|
||||||
|
# production deployment — do not commit to the dev appsettings
|
||||||
|
Secrets__MasterKey__Source=File
|
||||||
|
Secrets__MasterKey__FilePath=/run/secrets/otopcua-master.key
|
||||||
|
Secrets__SqlitePath=/shared/secrets/otopcua-secrets.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — a production-only config layer** that is *not* the committed dev
|
||||||
|
base (e.g. an untracked `appsettings.Production.json` deployed alongside the
|
||||||
|
binaries, or an orchestrator-injected config mount):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// production deployment — do not commit to the dev appsettings
|
||||||
|
{
|
||||||
|
"Secrets": {
|
||||||
|
"MasterKey": {
|
||||||
|
"Source": "File",
|
||||||
|
"FilePath": "/run/secrets/otopcua-master.key"
|
||||||
|
},
|
||||||
|
"SqlitePath": "/shared/secrets/otopcua-secrets.db"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way, the file/path referenced must exist and be identical on every node
|
||||||
|
**before** that node boots — the pre-host expander runs unconditionally on every
|
||||||
|
role and will throw (`SecretNotFoundException` / migration failure) if the store
|
||||||
|
or key is missing.
|
||||||
|
|
||||||
|
## Caveat: SQLite over a shared volume is not real replication
|
||||||
|
|
||||||
|
SQLite's file-locking model does not tolerate concurrent multi-writer access well
|
||||||
|
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
|
||||||
|
block volume only one writer should be active at a time). The interim posture
|
||||||
|
above is acceptable because:
|
||||||
|
|
||||||
|
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
|
||||||
|
- Writes are rare, human-initiated, and effectively single-writer in practice
|
||||||
|
(an operator runs the CLI/UI against one admin node at a time).
|
||||||
|
|
||||||
|
It is **not** a substitute for real replication, and it is not safe if multiple
|
||||||
|
nodes attempt concurrent writes. Do not build automation that writes secrets
|
||||||
|
from more than one node simultaneously.
|
||||||
|
|
||||||
|
## Data Protection is independent — do not touch it here
|
||||||
|
|
||||||
|
OtOpcUa's cookie/session protection already has its own clustered-key story:
|
||||||
|
`AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()`
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`),
|
||||||
|
which shares the Data Protection key ring across every node via the existing
|
||||||
|
ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryption
|
||||||
|
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
|
||||||
|
work — doing so risks invalidating active sessions for an unrelated reason.
|
||||||
|
|
||||||
|
It is, however, the model for where the *next* iteration of secret storage
|
||||||
|
should go — see the G-7 hand-off below.
|
||||||
|
|
||||||
|
## The G-7 hand-off
|
||||||
|
|
||||||
|
The posture above is an interim, ops-only workaround. The long-term shape,
|
||||||
|
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
|
||||||
|
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
|
||||||
|
the Data Protection key ring:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddDataProtection()
|
||||||
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||||
|
.SetApplicationName("OtOpcUa");
|
||||||
|
```
|
||||||
|
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
|
||||||
|
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
|
||||||
|
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
|
||||||
|
the secret store is the natural next tenant of that same shared database instead
|
||||||
|
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
|
||||||
|
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
|
||||||
|
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
|
||||||
|
built as part of this cut. This runbook's shared-SQLite-volume posture is the
|
||||||
|
bridge until G-7 lands.
|
||||||
|
|
||||||
|
## Dev/test/default posture (unchanged)
|
||||||
|
|
||||||
|
The committed default in `appsettings.json` is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "otopcua-secrets.db",
|
||||||
|
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
|
||||||
|
"RunMigrationsOnStartup": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
|
||||||
|
path is relative to the working directory, so local dev, the role-overlay
|
||||||
|
appsettings (`appsettings.admin.json`, `appsettings.driver.json`,
|
||||||
|
`appsettings.admin-driver.json`), and the `TwoNodeClusterHarness` integration
|
||||||
|
tests all boot cleanly with no external mount. The File-KEK + shared-volume
|
||||||
|
posture in this runbook applies only to real clustered production deployments —
|
||||||
|
it must never be baked into the committed dev/role-overlay base, because the
|
||||||
|
expander runs unconditionally at every node boot (any role) and would break
|
||||||
|
dev/CI if pointed at a nonexistent `/shared` mount.
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Alarm condition Quality (issue #477) — design
|
||||||
|
|
||||||
|
**Status:** implemented (L1+L2) · **Date:** 2026-07-17 · **Issue:** #477 (follow-up chain #473 → #475 → #477)
|
||||||
|
**Scope decision:** Layer 1 + Layer 2, Bad-direct, annotate-only. Layer 3 (scripted worst-of-input) deferred → **#478**.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`AlarmConditionState.Quality` is never assigned anywhere in `src/` — neither by
|
||||||
|
`OtOpcUaNodeManager.MaterialiseAlarmCondition` nor by the `WriteAlarmCondition` transition path.
|
||||||
|
Because `StatusCodes.Good == 0x00000000`, `default(StatusCode)` **is** `Good`, so the field is
|
||||||
|
*accidentally valid* — clients parse it, but it reports **`Good` unconditionally regardless of the
|
||||||
|
backing tag's real quality**.
|
||||||
|
|
||||||
|
This is a wrong-*value* bug, not the null-value bug class of #473/#475. Part 9 defines
|
||||||
|
`ConditionType.Quality` as "the quality of the Condition's source data". OT impact: when a native
|
||||||
|
alarm's device goes offline (comms lost) the condition still reports `Quality = Good`, so an operator
|
||||||
|
(or an HMI bucketing on `IsGood`) cannot distinguish *"genuinely not active"* from *"we have lost
|
||||||
|
contact and do not know"*.
|
||||||
|
|
||||||
|
## Why it isn't a 2-line default (confirmed by code)
|
||||||
|
|
||||||
|
1. **Alarm-bearing raw tags have no value variable.** `AddressSpaceApplier` materialises a raw tag as
|
||||||
|
*either* a condition node (`tag.Alarm is not null`) *or* a value variable (`else`) — never both,
|
||||||
|
since they'd share the same `s=<RawPath>` NodeId. So `WriteValue` (the only path carrying
|
||||||
|
`OpcUaQuality`) is never invoked for an alarm node. Quality has nowhere to land today.
|
||||||
|
2. **The alarm channel is quality-blind.** `AlarmEventArgs` (driver → host) and `AlarmConditionSnapshot`
|
||||||
|
(host → SDK sink) both carry no quality field.
|
||||||
|
3. **On comms-loss the alarm feed goes silent.** `DriverInstanceActor` on `DisconnectObserved` detaches
|
||||||
|
the alarm subscription and re-enters `Reconnecting` — no transition event ever arrives to carry Bad.
|
||||||
|
So the "device offline" signal must come from **driver connectivity**, independently of alarm
|
||||||
|
transitions.
|
||||||
|
|
||||||
|
## Decisions (the issue's open questions)
|
||||||
|
|
||||||
|
| # | Question | Decision | Rationale |
|
||||||
|
|---|----------|----------|-----------|
|
||||||
|
| 1 | Does an alarm tag get quality today? | No | Confirmed above — new plumbing required. |
|
||||||
|
| 2 | Direct status code vs. policy map | **Direct Bad** on comms-loss; Good on reconnect | Matches how a value variable would read; unambiguous for `IsGood` bucketing. |
|
||||||
|
| 3 | Does Bad also suppress transitions / touch Retain? | **No — annotate only** | A comms-lost *active* condition must stay active + retained. Silently clearing an active alarm on comms-loss is the unsafe direction. Quality is a pure annotation; the Active/Ack/Retain state machine is untouched. |
|
||||||
|
| 4 | Scripted alarms: worst-of-inputs quality? | **Deferred (Layer 3)** | Scripted conditions stay `Good`. Filed as a follow-up issue. |
|
||||||
|
|
||||||
|
## Architecture — reuse the existing publish path, add no sink method
|
||||||
|
|
||||||
|
The key move: **do not add a new `IOpcUaAddressSpaceSink` method.** A new sink-interface surface would
|
||||||
|
have to be forwarded through `DeferredAddressSpaceSink` or it is inert on driver hosts (the F10b
|
||||||
|
prod-inertness trap). Instead the `NativeAlarmProjector` becomes the single owner of per-condition
|
||||||
|
state *and* quality, and a connectivity change re-projects the *last* snapshot with a swapped quality
|
||||||
|
through the **existing** `AlarmStateUpdate → OpcUaPublishActor → WriteAlarmCondition` path.
|
||||||
|
|
||||||
|
### Layer 1 — make Quality a real, plumbed field
|
||||||
|
|
||||||
|
- `AlarmConditionSnapshot` (Commons) gains `OpcUaQuality Quality` (last positional param, default
|
||||||
|
`OpcUaQuality.Good` so scripted callers and existing tests keep compiling; Commons already knows
|
||||||
|
`OpcUaQuality` via `IOpcUaAddressSpaceSink`).
|
||||||
|
- `MaterialiseAlarmCondition` sets `alarm.Quality.Value` at build time:
|
||||||
|
**native → `BadWaitingForInitialData`** (honest until connectivity confirms Good, matching the
|
||||||
|
value-variable "waiting for initial data" convention), **scripted → `Good`** (script-computed, always
|
||||||
|
live in this scope).
|
||||||
|
- `WriteAlarmCondition` projects `StatusFromQuality(state.Quality)` onto `condition.Quality.Value`
|
||||||
|
(+ `SourceTimestamp`).
|
||||||
|
- The delta-gate (`AlarmConditionDelta` / `ReadConditionDelta` / `ToConditionDelta`) gains a `Quality`
|
||||||
|
member, so a Good→Bad bucket change is a genuine delta and **fires a Part 9 condition event**.
|
||||||
|
|
||||||
|
### Layer 2 — drive native quality from driver connectivity
|
||||||
|
|
||||||
|
- `DriverInstanceActor`: new `public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected)`.
|
||||||
|
`Context.Parent.Tell` it on `Become(Connected)` entry (`true`) and on the transitions into
|
||||||
|
`Reconnecting` (`DisconnectObserved` / `ForceReconnect`) (`false`). Fire-and-forget, mirrors
|
||||||
|
`DeltaApplied`.
|
||||||
|
- `NativeAlarmProjector`: per-node state becomes `(bool Active, bool Acked, OpcUaQuality Quality)`.
|
||||||
|
`Project(transition)` preserves the current quality; new `ProjectQuality(nodeId, quality)` preserves
|
||||||
|
Active/Acked and swaps only the quality, returning a full snapshot.
|
||||||
|
- `DriverHostActor`: `Receive<ConnectivityChanged>` iterates `_alarmNodeIdByDriverRef` for that driver
|
||||||
|
instance and Tells one `AlarmStateUpdate` per condition with the re-projected snapshot
|
||||||
|
(`connected ? Good : Bad`). **Ungated** — both redundancy nodes track their own driver's comms, matching
|
||||||
|
the existing "condition write stays ungated (Secondary keeps its address space warm)" rule.
|
||||||
|
**No `/alerts` row** for a quality-only change — driver health already has its own status/alerts surface
|
||||||
|
via `IDriverHealthPublisher`; a row here would be alarm-fatigue.
|
||||||
|
|
||||||
|
Scripted alarms are unaffected: they are not driver instances, receive no `ConnectivityChanged`, and
|
||||||
|
their snapshot quality stays `Good`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
**Layer 1**
|
||||||
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs`
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`MaterialiseAlarmCondition`,
|
||||||
|
`WriteAlarmCondition`, `AlarmConditionDelta`/`ReadConditionDelta`/`ToConditionDelta`)
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` (`ToSnapshot` — Quality=Good, or rely on default)
|
||||||
|
|
||||||
|
**Layer 2**
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs`
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs`
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
|
||||||
|
|
||||||
|
## Tests (TDD, RED-first)
|
||||||
|
|
||||||
|
1. **Wire-level (the issue's suggested guard)** — extend `NativeAlarmEventIdentityFieldDeliveryTests`
|
||||||
|
(OpcUaServer.IntegrationTests): active alarm → event `Quality.IsGood`; driver disconnect → condition
|
||||||
|
event `Quality.IsGood == false`; reconnect → Good. Verify RED against pre-fix.
|
||||||
|
2. **Node-level** — `NodeManagerAlarmSourceFieldsTests`: materialise sets Quality (native
|
||||||
|
`BadWaitingForInitialData`, scripted `Good`); `WriteAlarmCondition` projects snapshot quality and
|
||||||
|
fires on a quality-bucket change only.
|
||||||
|
3. **`NativeAlarmProjector`** unit: `ProjectQuality` keeps Active/Acked + swaps quality; `Project`
|
||||||
|
preserves quality.
|
||||||
|
4. **`DriverInstanceActor`**: `Connected` entry Tells `ConnectivityChanged(true)`; `DisconnectObserved`
|
||||||
|
Tells `ConnectivityChanged(false)`.
|
||||||
|
5. **`DriverHostActor`**: `ConnectivityChanged(false)` pushes a Bad-quality `AlarmStateUpdate` to every
|
||||||
|
condition of that driver instance.
|
||||||
|
|
||||||
|
## Deferred / notes
|
||||||
|
|
||||||
|
- **Layer 3** (scripted worst-of-input quality) → **Gitea #478**.
|
||||||
|
- **Implementation note:** L2 uses a **dedicated `IOpcUaAddressSpaceSink.WriteAlarmQuality`** path (not a
|
||||||
|
full-snapshot re-projection). Rationale: a connectivity change must set *only* Quality; re-projecting a full
|
||||||
|
snapshot would clobber a cold condition's severity/message and can't annotate a condition that never fired a
|
||||||
|
transition. The new sink method is forwarded through `DeferredAddressSpaceSink` (the F10b inertness trap) —
|
||||||
|
auto-verified by `DeferredSinkForwardingReflectionTests` (reflection guard) + its realm-discriminator guard.
|
||||||
|
- **Test-harness note:** the new `DriverInstanceActor → parent` `ConnectivityChanged` Tell polluted existing
|
||||||
|
parent-`TestProbe` assertions in 3 `DriverInstanceActor*Tests` files; those tests now
|
||||||
|
`parent.IgnoreMessages(m => m is ConnectivityChanged)` since they assert on data/alarm/discovery forwards,
|
||||||
|
not connectivity.
|
||||||
|
- `Bad_NoCommunication` vs generic `Bad`: v1 maps `OpcUaQuality.Bad → StatusCodes.Bad`; refining
|
||||||
|
`StatusFromQuality` to emit `BadNoCommunication` for the comms-loss case is a one-line nicety, noted in
|
||||||
|
the issue.
|
||||||
|
- `docs/AlarmTracking.md` §"Condition event identity fields" gains a Quality subsection (Good/Bad
|
||||||
|
semantics, annotation-not-state-change, quality-bucket change fires an event).
|
||||||
|
|
||||||
|
## Layer 3 — scripted worst-of-input quality (Gitea #478, implemented 2026-07-17)
|
||||||
|
|
||||||
|
**Problem.** A scripted alarm is computed from one or more input tags. Its condition should report the
|
||||||
|
**worst** quality of those inputs ("can I trust this condition's state?"), not the hardcoded `Good` Layer 1
|
||||||
|
left at `ScriptedAlarmHostActor.ToSnapshot`.
|
||||||
|
|
||||||
|
**Two blockers discovered in the live path (both silently discard quality):**
|
||||||
|
1. `DependencyMuxActor.OnAttributeValuePublished` builds `VirtualTagActor.DependencyValueChanged` **without**
|
||||||
|
the `AttributeValuePublished.Quality` it already carries.
|
||||||
|
2. `ScriptedAlarmHostActor.OnDependencyChanged` pushes each mux value into the engine's upstream with a
|
||||||
|
**hardcoded `0u` (Good)** StatusCode.
|
||||||
|
So even a `Bad` driver value reached the scripted engine as Good — Layer 3 has to plumb quality first.
|
||||||
|
|
||||||
|
**Design (mirrors Layer 2's native OT semantic through the scripted channel):**
|
||||||
|
- **Plumb quality end-to-end.** `DependencyValueChanged` gains `OpcUaQuality Quality` (defaulted `Good`, so the
|
||||||
|
virtual-tag engine's calls are unchanged); the mux forwards `msg.Quality`; `OnDependencyChanged` maps it to a
|
||||||
|
StatusCode (`Good→0`, `Uncertain→0x40000000`, `Bad→0x80000000`) on the pushed `DataValueSnapshot`.
|
||||||
|
- **Engine computes the worst input quality** each evaluation (over the refilled read cache, **before** the
|
||||||
|
`AreInputsReady` short-circuit so a `Bad` input is still observed) and carries it as
|
||||||
|
`ScriptedAlarmEvent.WorstInputStatusCode` (a raw `uint` StatusCode — `Core.ScriptedAlarms` doesn't reference
|
||||||
|
Commons, so it stays in the engine's existing StatusCode vocabulary; the host maps it to `OpcUaQuality`).
|
||||||
|
- **Transitions carry the current worst quality** → `ToSnapshot` projects it (no clobber-back-to-Good when a
|
||||||
|
transition fires while an input is `Uncertain`).
|
||||||
|
- **Quality-only changes emit out of band.** A `Bad` input freezes the condition (`AreInputsReady` returns
|
||||||
|
false → no transition), exactly like a comms-lost native driver — so quality can't ride a transition. The
|
||||||
|
engine tracks the last worst-quality **bucket** per alarm and, when the bucket changes with **no** transition
|
||||||
|
emission, emits a new `EmissionKind.QualityChanged` event. The host routes that to the **existing** Layer 2
|
||||||
|
`OpcUaPublishActor.AlarmQualityUpdate → IOpcUaAddressSpaceSink.WriteAlarmQuality` path (sets ONLY Quality,
|
||||||
|
one Part 9 event on a bucket change, **no `/alerts` row**, no historian write). No new sink surface.
|
||||||
|
- **`ScriptedAlarmSource` (the `IAlarmSource` fan-out adapter) skips `QualityChanged`** — quality is delivered
|
||||||
|
through the dedicated node path, never as a phantom `AlarmEventArgs` (which would materialize/historize a
|
||||||
|
native condition).
|
||||||
|
|
||||||
|
**Files (Layer 3):**
|
||||||
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs` — `EmissionKind.QualityChanged`.
|
||||||
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` — worst-of-input, bucket tracking,
|
||||||
|
`WorstInputStatusCode` on the event, quality-only emission.
|
||||||
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs` — skip `QualityChanged`.
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — `DependencyValueChanged.Quality`.
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — forward `msg.Quality`.
|
||||||
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` — push real quality;
|
||||||
|
`ToSnapshot` maps `WorstInputStatusCode`; `OnEngineEmission` routes `QualityChanged → AlarmQualityUpdate`.
|
||||||
|
|
||||||
|
**Tests (RED-first):** engine — transition carries `Uncertain` worst; `Bad` input with no transition emits
|
||||||
|
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; no spurious emit when the bucket is unchanged.
|
||||||
|
`ScriptedAlarmSource` — `QualityChanged` raises no `OnAlarmEvent`. Mux — `DependencyValueChanged` carries the
|
||||||
|
published quality. Host — `Bad` dependency → `AlarmQualityUpdate(Bad)`, no `/alerts` publish; `ToSnapshot`
|
||||||
|
maps the event's worst quality.
|
||||||
|
|
||||||
|
**Coverage boundary → Layer 4 (#481).** L3 covers inputs whose driver **publishes a Bad/Uncertain-status
|
||||||
|
data change** (the mux quality path). It does **not** cover a driver **comms loss**: a poll driver
|
||||||
|
(Modbus/S7) whose device goes unreachable emits only `ConnectivityChanged` and goes silent on the value feed
|
||||||
|
(`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
|
||||||
|
condition stays Good — the same silent-feed problem native solved in L2, but native's `OnDriverConnectivityChanged`
|
||||||
|
bridge fans only to **native** condition nodes (`_alarmNodeIdByDriverRef`), not into the mux the scripted
|
||||||
|
engine reads. Bridging connectivity into scripted inputs — plus resolving the null-value/cold-start
|
||||||
|
asymmetry (a runtime `Bad` with a null value is currently indistinguishable from cold start and contributes
|
||||||
|
`Good`) and its ripple into virtual-tag quality — is **Gitea #481 (Layer 4)**. Found by the post-implementation
|
||||||
|
code review; the code as shipped faithfully implements #478's written scope (mux-delivered input quality).
|
||||||
@@ -380,6 +380,55 @@ polling the node.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Config secrets (`${secret:}` delivery)
|
||||||
|
|
||||||
|
OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is
|
||||||
|
supplied at deploy time — either as a plain environment variable, or as a
|
||||||
|
`${secret:<name>}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store.
|
||||||
|
|
||||||
|
A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||||
|
`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every
|
||||||
|
`${secret:<name>}` token into its resolved plaintext **before** the host is built —
|
||||||
|
so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first
|
||||||
|
`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from
|
||||||
|
the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`);
|
||||||
|
the encrypted SQLite store lives at `Secrets:SqlitePath`.
|
||||||
|
|
||||||
|
The expander is **fail-closed and section-agnostic**: a `${secret:<name>}` token whose
|
||||||
|
secret is absent throws `SecretNotFoundException` at startup, regardless of which feature
|
||||||
|
owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are
|
||||||
|
skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved.
|
||||||
|
|
||||||
|
The five config secrets and their canonical secret names:
|
||||||
|
|
||||||
|
| Config key | Owner | Secret name |
|
||||||
|
|---|---|---|
|
||||||
|
| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` |
|
||||||
|
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` |
|
||||||
|
| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` |
|
||||||
|
| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` |
|
||||||
|
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` |
|
||||||
|
|
||||||
|
**Delivery.** By default these are delivered as plain environment variables (e.g.
|
||||||
|
`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted
|
||||||
|
store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply
|
||||||
|
the token in place of the literal — via env var or a deployment appsettings overlay:
|
||||||
|
|
||||||
|
```
|
||||||
|
secret set otopcua/historian/api-key <value> # seed the encrypted store first
|
||||||
|
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded
|
||||||
|
token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any
|
||||||
|
secret value.
|
||||||
|
|
||||||
|
For the production posture needed so every clustered node (admin, driver, and any
|
||||||
|
fused role) resolves the same secrets from the same store, see
|
||||||
|
[`docs/operations/2026-07-16-secrets-clustered-master-key.md`](operations/2026-07-16-secrets-clustered-master-key.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Certificate trust failure
|
### Certificate trust failure
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|||||||
/// <param name="Shelving">The shelving mode (ShelvingState): unshelved, one-shot, or timed.</param>
|
/// <param name="Shelving">The shelving mode (ShelvingState): unshelved, one-shot, or timed.</param>
|
||||||
/// <param name="Severity">OPC UA severity on the 1..1000 scale (the SDK <c>SetSeverity</c> input).</param>
|
/// <param name="Severity">OPC UA severity on the 1..1000 scale (the SDK <c>SetSeverity</c> input).</param>
|
||||||
/// <param name="Message">The human-readable condition message (LocalizedText payload).</param>
|
/// <param name="Message">The human-readable condition message (LocalizedText payload).</param>
|
||||||
|
/// <param name="Quality">
|
||||||
|
/// Quality of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
|
||||||
|
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
|
||||||
|
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
|
||||||
|
/// <see cref="OpcUaQuality.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
|
||||||
|
/// </param>
|
||||||
public sealed record AlarmConditionSnapshot(
|
public sealed record AlarmConditionSnapshot(
|
||||||
bool Active,
|
bool Active,
|
||||||
bool Acknowledged,
|
bool Acknowledged,
|
||||||
@@ -23,7 +29,8 @@ public sealed record AlarmConditionSnapshot(
|
|||||||
bool Enabled,
|
bool Enabled,
|
||||||
AlarmShelvingKind Shelving,
|
AlarmShelvingKind Shelving,
|
||||||
ushort Severity,
|
ushort Severity,
|
||||||
string Message);
|
string Message,
|
||||||
|
OpcUaQuality Quality = OpcUaQuality.Good);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
|
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
|
=> _inner.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// the condition was materialised under).</param>
|
/// the condition was materialised under).</param>
|
||||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
|
/// <summary>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
|
||||||
|
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>,
|
||||||
|
/// restored → <see cref="OpcUaQuality.Good"/>). Sets ONLY the condition's Quality — never
|
||||||
|
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
|
||||||
|
/// only on a quality-bucket change. A no-op for an unmaterialised / non-condition node.</summary>
|
||||||
|
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||||
|
/// <param name="quality">The source-data quality to annotate.</param>
|
||||||
|
/// <param name="sourceTimestampUtc">The connectivity transition timestamp in UTC.</param>
|
||||||
|
/// <param name="realm">The namespace realm the condition was materialised under.</param>
|
||||||
|
void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||||
/// can browse it as a proper condition (with basic Active/Ack state). The node id equals the
|
/// can browse it as a proper condition (with basic Active/Ack state). The node id equals the
|
||||||
@@ -165,6 +176,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
|
|||||||
@@ -377,4 +377,9 @@ public enum EmissionKind
|
|||||||
Enabled,
|
Enabled,
|
||||||
Disabled,
|
Disabled,
|
||||||
CommentAdded,
|
CommentAdded,
|
||||||
|
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
|
||||||
|
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
|
||||||
|
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
|
||||||
|
/// state change and must not materialize or historize a condition).</summary>
|
||||||
|
QualityChanged,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
|
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
|
||||||
|
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
|
||||||
|
/// with no accompanying Part 9 state transition drives a standalone
|
||||||
|
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
|
||||||
|
/// transition — so quality can't ride one, exactly like a comms-lost native driver). Only ever
|
||||||
|
/// mutated under <c>_evalGate</c>; cleared alongside <see cref="_alarms"/> on load/dispose.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ConcurrentDictionary<string, int> _lastQualityBucketByAlarmId =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
||||||
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
||||||
@@ -203,6 +214,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
// have changed (different Inputs, different Logger), so any reuse would be
|
// have changed (different Inputs, different Logger), so any reuse would be
|
||||||
// unsafe.
|
// unsafe.
|
||||||
_scratchByAlarmId.Clear();
|
_scratchByAlarmId.Clear();
|
||||||
|
_lastQualityBucketByAlarmId.Clear();
|
||||||
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
||||||
// recompile this one. Skipping this is what made the earlier fix a
|
// recompile this one. Skipping this is what made the earlier fix a
|
||||||
// no-op in production.
|
// no-op in production.
|
||||||
@@ -412,7 +424,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
// OnEvent dispatch until after Release() so a slow subscriber or a
|
// OnEvent dispatch until after Release() so a slow subscriber or a
|
||||||
// subscriber that re-enters the engine doesn't block / deadlock.
|
// subscriber that re-enters the engine doesn't block / deadlock.
|
||||||
if (result.Emission != EmissionKind.None)
|
if (result.Emission != EmissionKind.None)
|
||||||
pending = BuildEmission(state, result.State, result.Emission);
|
pending = BuildEmission(state, result.State, result.Emission, LastWorstStatus(alarmId));
|
||||||
else if (result.NoOpReason is { } reason)
|
else if (result.NoOpReason is { } reason)
|
||||||
{
|
{
|
||||||
// The Part9StateMachine remarks promise a diagnostic log line for
|
// The Part9StateMachine remarks promise a diagnostic log line for
|
||||||
@@ -513,13 +525,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
||||||
RefillReadCache(scratch.ReadCache, state.Inputs);
|
RefillReadCache(scratch.ReadCache, state.Inputs);
|
||||||
|
|
||||||
|
// #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
|
||||||
|
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
|
||||||
|
// transition is delivered out of band as a QualityChanged emission (see below).
|
||||||
|
var worstStatus = WorstInputStatus(scratch.ReadCache);
|
||||||
|
var qualityBucketChanged = TrackQualityBucket(state.Definition.AlarmId, worstStatus);
|
||||||
|
|
||||||
// Cold-start guard — skip the predicate when any referenced upstream tag has no
|
// Cold-start guard — skip the predicate when any referenced upstream tag has no
|
||||||
// cached value yet (the upstream subscription hasn't delivered its first push).
|
// cached value yet (the upstream subscription hasn't delivered its first push).
|
||||||
// Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on
|
// Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on
|
||||||
// every tick until the cache fills, spamming the log with identical stack traces.
|
// every tick until the cache fills, spamming the log with identical stack traces.
|
||||||
// Bad quality is treated the same: the input isn't available at the predicate's
|
// Bad quality is treated the same: the input isn't available at the predicate's
|
||||||
// expected type, so the only defensible move is to hold the prior condition state.
|
// expected type, so the only defensible move is to hold the prior condition state.
|
||||||
if (!AreInputsReady(scratch.ReadCache)) return seed;
|
if (!AreInputsReady(scratch.ReadCache))
|
||||||
|
{
|
||||||
|
// The condition is frozen (can't trust its state), but its source quality just changed
|
||||||
|
// bucket — annotate it out of band so a comms-lost / Bad-input scripted condition reports
|
||||||
|
// Bad, mirroring the native OT path.
|
||||||
|
if (qualityBucketChanged)
|
||||||
|
pendingEmissions.Add(BuildQualityEmission(state, seed, worstStatus));
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
|
||||||
var context = scratch.Context;
|
var context = scratch.Context;
|
||||||
|
|
||||||
@@ -544,10 +570,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
|
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
|
||||||
if (result.Emission != EmissionKind.None)
|
var transition = result.Emission != EmissionKind.None
|
||||||
|
? BuildEmission(state, result.State, result.Emission, worstStatus)
|
||||||
|
: null;
|
||||||
|
if (transition is not null)
|
||||||
{
|
{
|
||||||
var evt = BuildEmission(state, result.State, result.Emission);
|
// A real transition carries the current worst quality so the projected full-snapshot
|
||||||
if (evt is not null) pendingEmissions.Add(evt);
|
// write doesn't clobber quality back to Good (e.g. a transition while an input is Uncertain).
|
||||||
|
pendingEmissions.Add(transition);
|
||||||
|
}
|
||||||
|
else if (qualityBucketChanged)
|
||||||
|
{
|
||||||
|
// No transition (or a Suppressed one) but the quality bucket moved — annotate out of band.
|
||||||
|
pendingEmissions.Add(BuildQualityEmission(state, result.State, worstStatus));
|
||||||
}
|
}
|
||||||
return result.State;
|
return result.State;
|
||||||
}
|
}
|
||||||
@@ -599,7 +634,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
||||||
/// released.
|
/// released.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private ScriptedAlarmEvent? BuildEmission(AlarmState state, AlarmConditionState condition, EmissionKind kind)
|
private ScriptedAlarmEvent? BuildEmission(
|
||||||
|
AlarmState state, AlarmConditionState condition, EmissionKind kind, uint worstInputStatus)
|
||||||
{
|
{
|
||||||
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
||||||
// but the state record still advanced so startup recovery reflects reality.
|
// but the state record still advanced so startup recovery reflects reality.
|
||||||
@@ -629,9 +665,89 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
// Carry the per-alarm durable-historization opt-out through to subscribers. The historian
|
// Carry the per-alarm durable-historization opt-out through to subscribers. The historian
|
||||||
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
||||||
// unaffected (it is not gated on this flag).
|
// unaffected (it is not gated on this flag).
|
||||||
HistorizeToAveva: state.Definition.HistorizeToAveva);
|
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||||
|
// #478 — the worst input quality at evaluation time rides the transition so the projected
|
||||||
|
// full snapshot keeps quality consistent (no clobber-to-Good).
|
||||||
|
WorstInputStatusCode: worstInputStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
|
||||||
|
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
|
||||||
|
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
|
||||||
|
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
|
||||||
|
/// no <c>/alerts</c> row, no historian write); the <see cref="IAlarmSource"/> fan-out skips it.
|
||||||
|
/// </summary>
|
||||||
|
private ScriptedAlarmEvent BuildQualityEmission(
|
||||||
|
AlarmState state, AlarmConditionState condition, uint worstInputStatus)
|
||||||
|
=> new(
|
||||||
|
AlarmId: state.Definition.AlarmId,
|
||||||
|
EquipmentPath: state.Definition.EquipmentPath,
|
||||||
|
AlarmName: state.Definition.AlarmName,
|
||||||
|
Kind: state.Definition.Kind,
|
||||||
|
Severity: state.Definition.Severity,
|
||||||
|
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
|
||||||
|
Condition: condition,
|
||||||
|
Emission: EmissionKind.QualityChanged,
|
||||||
|
TimestampUtc: _clock(),
|
||||||
|
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||||
|
WorstInputStatusCode: worstInputStatus);
|
||||||
|
|
||||||
|
/// <summary>Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity
|
||||||
|
/// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a
|
||||||
|
/// not-yet-published upstream) is NOT a quality signal: it means "no data", which the
|
||||||
|
/// <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it as Bad here
|
||||||
|
/// would flash every scripted condition Bad at deploy until the first push and would flood the quality
|
||||||
|
/// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread
|
||||||
|
/// cache ⇒ Good (0).</summary>
|
||||||
|
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||||
|
{
|
||||||
|
uint worst = 0u;
|
||||||
|
var worstSeverity = 0u;
|
||||||
|
foreach (var kv in cache)
|
||||||
|
{
|
||||||
|
if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal
|
||||||
|
var status = kv.Value.StatusCode;
|
||||||
|
var severity = status >> 30;
|
||||||
|
if (severity > worstSeverity)
|
||||||
|
{
|
||||||
|
worstSeverity = severity;
|
||||||
|
worst = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket
|
||||||
|
/// (0 = Good, 1 = Uncertain, 2 = Bad) changed from the last observed value. Only called under
|
||||||
|
/// <c>_evalGate</c>.</summary>
|
||||||
|
private bool TrackQualityBucket(string alarmId, uint worstStatus)
|
||||||
|
{
|
||||||
|
var bucket = QualityBucket(worstStatus);
|
||||||
|
var prior = _lastQualityBucketByAlarmId.TryGetValue(alarmId, out var b) ? b : 0; // default Good
|
||||||
|
_lastQualityBucketByAlarmId[alarmId] = bucket;
|
||||||
|
return bucket != prior;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collapse an OPC UA StatusCode's 2 severity bits (00/01/10/11) to a 3-state quality bucket
|
||||||
|
/// (0 = Good, 1 = Uncertain, 2 = Bad).</summary>
|
||||||
|
private static int QualityBucket(uint statusCode)
|
||||||
|
{
|
||||||
|
var severity = statusCode >> 30;
|
||||||
|
return severity >= 2 ? 2 : (int)severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by
|
||||||
|
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
|
||||||
|
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
|
||||||
|
private uint LastWorstStatus(string alarmId)
|
||||||
|
=> (_lastQualityBucketByAlarmId.TryGetValue(alarmId, out var bucket) ? bucket : 0) switch
|
||||||
|
{
|
||||||
|
2 => 0x80000000u, // Bad
|
||||||
|
1 => 0x40000000u, // Uncertain
|
||||||
|
_ => 0u, // Good
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
|
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
|
||||||
/// called OUTSIDE <c>_evalGate</c>: a slow subscriber would otherwise
|
/// called OUTSIDE <c>_evalGate</c>: a slow subscriber would otherwise
|
||||||
@@ -708,7 +824,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
_alarms[id] = state with { Condition = result.State };
|
_alarms[id] = state with { Condition = result.State };
|
||||||
if (result.Emission != EmissionKind.None)
|
if (result.Emission != EmissionKind.None)
|
||||||
{
|
{
|
||||||
var evt = BuildEmission(state, result.State, result.Emission);
|
var evt = BuildEmission(state, result.State, result.Emission, LastWorstStatus(id));
|
||||||
if (evt is not null) pending.Add(evt);
|
if (evt is not null) pending.Add(evt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,6 +896,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
_alarms.Clear();
|
_alarms.Clear();
|
||||||
_alarmsReferencing.Clear();
|
_alarmsReferencing.Clear();
|
||||||
_scratchByAlarmId.Clear();
|
_scratchByAlarmId.Clear();
|
||||||
|
_lastQualityBucketByAlarmId.Clear();
|
||||||
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
||||||
// releases the emitted assemblies. The drain above ensures no evaluator is
|
// releases the emitted assemblies. The drain above ensures no evaluator is
|
||||||
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
||||||
@@ -851,7 +968,11 @@ public sealed record ScriptedAlarmEvent(
|
|||||||
EmissionKind Emission,
|
EmissionKind Emission,
|
||||||
DateTime TimestampUtc,
|
DateTime TimestampUtc,
|
||||||
string? Comment = null,
|
string? Comment = null,
|
||||||
bool HistorizeToAveva = true);
|
bool HistorizeToAveva = true,
|
||||||
|
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint
|
||||||
|
// (Core.ScriptedAlarms does not reference Commons/OpcUaQuality); the host maps it to OpcUaQuality by
|
||||||
|
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
|
||||||
|
uint WorstInputStatusCode = 0u);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
|||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|
||||||
|
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
||||||
|
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
|
||||||
|
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
|
||||||
|
if (evt.Emission == EmissionKind.QualityChanged) return;
|
||||||
|
|
||||||
foreach (var sub in _subscriptions.Values)
|
foreach (var sub in _subscriptions.Values)
|
||||||
{
|
{
|
||||||
if (!Matches(sub, evt)) continue;
|
if (!Matches(sub, evt)) continue;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
|
|
||||||
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
|
|
||||||
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
||||||
|
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c> (DI-injected).</param>
|
||||||
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
||||||
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
|
public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger<GalaxyDriverBrowser>? logger = null)
|
||||||
{
|
{
|
||||||
|
_secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||||
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
||||||
|
|
||||||
var clientOpts = BuildClientOptions(opts.Gateway);
|
var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||||
// an AdminUI cancel still wins early.
|
// an AdminUI cancel still wins early.
|
||||||
@@ -116,13 +120,21 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
||||||
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||||
/// gateway sees an identical option shape. The API-key reference is resolved via
|
/// gateway sees an identical option shape. The API-key reference is resolved via
|
||||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
|
/// the shared <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> in Driver.Galaxy.Contracts
|
||||||
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
||||||
|
/// The <c>secret:</c> arm is async, so the key is resolved into a local before the
|
||||||
|
/// object initializer (you can't await inside one).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||||
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var apiKey = await GalaxySecretRef
|
||||||
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return new MxGatewayClientOptions
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
ApiKey = apiKey,
|
||||||
UseTls = gw.UseTls,
|
UseTls = gw.UseTls,
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
CaCertificatePath = gw.CaCertificatePath,
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||||
@@ -132,3 +144,4 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
|
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
|
||||||
/// supported, in priority order:
|
/// supported, in priority order:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||||
/// production; the central config DB holds only the indirection, not the key).</item>
|
/// production; the central config DB holds only the indirection, not the key).</item>
|
||||||
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
||||||
|
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
|
||||||
|
/// encrypted store; fail-closed if absent (the production path).</item>
|
||||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||||
/// no startup warning.</item>
|
/// no startup warning.</item>
|
||||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
|
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
|
||||||
/// forms supported, evaluated in order:
|
/// forms supported, evaluated in order:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
||||||
@@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|||||||
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
||||||
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
|
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
|
||||||
/// doesn't emit a warning; production should never use this arm.</item>
|
/// doesn't emit a warning; production should never use this arm.</item>
|
||||||
|
/// <item><c>secret:NAME</c> — resolves NAME through the shared
|
||||||
|
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
|
||||||
|
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
|
||||||
|
/// throws rather than falling through to the literal arm — the production path
|
||||||
|
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
|
||||||
/// <item>Anything else — used as the literal API key for back-compat with
|
/// <item>Anything else — used as the literal API key for back-compat with
|
||||||
/// configs that pre-date this resolver. When a logger is supplied the
|
/// configs that pre-date this resolver. When a logger is supplied the
|
||||||
/// resolver emits a startup warning so an operator who accidentally
|
/// resolver emits a startup warning so an operator who accidentally
|
||||||
/// committed a cleartext key sees it.</item>
|
/// committed a cleartext key sees it.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
/// A future PR can swap any of these arms for a different backing store without
|
||||||
/// changing the call site.
|
/// changing the call site.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|||||||
public static class GalaxySecretRef
|
public static class GalaxySecretRef
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
||||||
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
|
/// is absent). When the ref falls through to the back-compat literal arm (an
|
||||||
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
|
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
|
||||||
/// opt-in path that doesn't warn.
|
/// <paramref name="logger"/> is supplied, emits a <see cref="LogLevel.Warning"/>.
|
||||||
|
/// The <c>dev:</c> prefix is the explicit opt-in path that doesn't warn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
||||||
/// <returns>The resolved API-key string.</returns>
|
/// <returns>The resolved API-key string.</returns>
|
||||||
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
|
public static async Task<string> ResolveApiKeyAsync(
|
||||||
|
string secretRef,
|
||||||
|
ISecretResolver resolver,
|
||||||
|
ILogger? logger = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolver);
|
||||||
|
|
||||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
|
|||||||
return secretRef[4..];
|
return secretRef[4..];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Production path: resolve the name through the shared encrypted secret store.
|
||||||
|
// Fail-closed — an absent/tombstoned secret throws rather than falling through
|
||||||
|
// to the literal arm (which would silently treat the ref string as the key).
|
||||||
|
var name = secretRef["secret:".Length..];
|
||||||
|
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||||
|
return !string.IsNullOrEmpty(value)
|
||||||
|
? value
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
|
||||||
|
}
|
||||||
|
|
||||||
// Back-compat literal arm. An unprefixed string is treated as the literal
|
// Back-compat literal arm. An unprefixed string is treated as the literal
|
||||||
// API key — but emit a warning so an operator who accidentally committed a
|
// API key — but emit a warning so an operator who accidentally committed a
|
||||||
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
|
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
|
||||||
|
|||||||
+1
@@ -13,5 +13,6 @@
|
|||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||||
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
|
|||||||
private readonly GalaxyDriverOptions _options;
|
private readonly GalaxyDriverOptions _options;
|
||||||
private readonly ILogger<GalaxyDriver> _logger;
|
private readonly ILogger<GalaxyDriver> _logger;
|
||||||
|
|
||||||
|
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
|
||||||
|
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
|
||||||
|
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
|
||||||
|
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
|
|
||||||
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
||||||
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
||||||
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
||||||
@@ -147,14 +154,17 @@ public sealed class GalaxyDriver
|
|||||||
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||||
/// <param name="options">The Galaxy driver configuration options.</param>
|
/// <param name="options">The Galaxy driver configuration options.</param>
|
||||||
|
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
|
||||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||||
public GalaxyDriver(
|
public GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null)
|
||||||
: this(driverInstanceId, options,
|
: this(driverInstanceId, options,
|
||||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
||||||
alarmAcknowledger: null, alarmFeed: null, logger)
|
alarmAcknowledger: null, alarmFeed: null, logger,
|
||||||
|
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +183,11 @@ public sealed class GalaxyDriver
|
|||||||
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
||||||
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
||||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
|
||||||
|
/// null-object resolver (returns null for every name) so seam-injecting tests that
|
||||||
|
/// don't exercise a <c>secret:</c> ref need not supply one.
|
||||||
|
/// </param>
|
||||||
internal GalaxyDriver(
|
internal GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
|
|||||||
IGalaxySubscriber? subscriber = null,
|
IGalaxySubscriber? subscriber = null,
|
||||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||||
IGalaxyAlarmFeed? alarmFeed = null,
|
IGalaxyAlarmFeed? alarmFeed = null,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||||
? driverInstanceId
|
? driverInstanceId
|
||||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
_hierarchySource = hierarchySource;
|
_hierarchySource = hierarchySource;
|
||||||
_dataReader = dataReader;
|
_dataReader = dataReader;
|
||||||
_dataWriter = dataWriter;
|
_dataWriter = dataWriter;
|
||||||
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
|
|||||||
_driverInstanceId);
|
_driverInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
StartDeployWatcher();
|
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
||||||
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
||||||
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
||||||
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
||||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
|
|||||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_ownedMxSession is null) return;
|
if (_ownedMxSession is null) return;
|
||||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
||||||
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
||||||
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||||
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||||
|
// async and you can't await inside an initializer. Pass the logger so the
|
||||||
|
// literal-arm cleartext fallback surfaces a startup warning rather than
|
||||||
|
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
|
||||||
|
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
||||||
|
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
||||||
|
var apiKey = await GalaxySecretRef
|
||||||
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return new MxGatewayClientOptions
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
|
ApiKey = apiKey,
|
||||||
// warning rather than silently shipping the key. The resolver lives in
|
|
||||||
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
|
|
||||||
// AdminUI browser share one implementation.
|
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
|
||||||
UseTls = gw.UseTls,
|
UseTls = gw.UseTls,
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
CaCertificatePath = gw.CaCertificatePath,
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void StartDeployWatcher()
|
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_options.Repository.WatchDeployEvents) return;
|
if (!_options.Repository.WatchDeployEvents) return;
|
||||||
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
||||||
|
|
||||||
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
||||||
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
||||||
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
|
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
||||||
// rather than overwriting the field and leaking the first instance.
|
// runs it reuses this client rather than overwriting the field and leaking the
|
||||||
|
// first instance — the client-options build is now async (secret: arm).
|
||||||
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||||
BuildClientOptions(_options.Gateway));
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||||
_deployWatcher = new DeployWatcher(source, _logger);
|
_deployWatcher = new DeployWatcher(source, _logger);
|
||||||
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
|
|||||||
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
||||||
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
||||||
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
||||||
var source = _hierarchySource ??= BuildDefaultHierarchySource();
|
var source = _hierarchySource ??=
|
||||||
|
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
|
||||||
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
||||||
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
||||||
// keyed by RawPath, matching what WriteAsync resolves against.
|
// keyed by RawPath, matching what WriteAsync resolves against.
|
||||||
@@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver
|
|||||||
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
||||||
/// internal ctor.
|
/// internal ctor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IGalaxyHierarchySource BuildDefaultHierarchySource()
|
private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Reuse a client that StartDeployWatcher may have already created (??=) rather
|
// Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
|
||||||
// than always overwriting the field and leaking the first instance. Both paths
|
// than always overwriting the field and leaking the first instance. Both paths
|
||||||
// produce equivalent clients from the same options.
|
// produce equivalent clients from the same options. The client-options build is
|
||||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
// async now (secret: arm resolves through the shared ISecretResolver).
|
||||||
|
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||||
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
return new TracedGalaxyHierarchySource(
|
return new TracedGalaxyHierarchySource(
|
||||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
{
|
{
|
||||||
public const string DriverTypeName = "GalaxyMxGateway";
|
public const string DriverTypeName = "GalaxyMxGateway";
|
||||||
|
|
||||||
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
|
/// <summary>Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory.</summary>
|
||||||
/// <param name="registry">The driver factory registry to register with.</param>
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of
|
||||||
|
/// each instance's <c>Gateway.ApiKeySecretRef</c>.
|
||||||
|
/// </param>
|
||||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
|
ILoggerFactory? loggerFactory = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Convenience for tests + standalone callers.</summary>
|
/// <summary>
|
||||||
|
/// Convenience for tests + standalone callers. Uses the <see cref="NullSecretResolver"/>
|
||||||
|
/// null-object, so the <c>secret:</c> API-key arm resolves fail-closed (absent) — callers
|
||||||
|
/// that need a working <c>secret:</c> ref must use the <see cref="Register"/> path that
|
||||||
|
/// threads the DI resolver.
|
||||||
|
/// </summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance);
|
||||||
|
|
||||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
|
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||||
|
/// <param name="secretResolver">The secret resolver for the driver's <c>secret:</c> API-key arm.</param>
|
||||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(
|
public static GalaxyDriver CreateInstance(
|
||||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
RawTags = dto.RawTags ?? [],
|
RawTags = dto.RawTags ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>());
|
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||||
|
/// (absent). Used as the default for the internal test ctor and the parse-only
|
||||||
|
/// <c>CreateInstance</c> path so callers that never exercise a <c>secret:</c> API-key
|
||||||
|
/// ref need not thread a real resolver. A production <c>GalaxyDriver</c> always receives
|
||||||
|
/// the DI-registered resolver via the factory; a <c>secret:</c> ref resolved against this
|
||||||
|
/// null object throws fail-closed (the secret is reported absent), which is the correct
|
||||||
|
/// behaviour for a mis-wired deployment.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <summary>The shared singleton instance.</summary>
|
||||||
|
public static readonly NullSecretResolver Instance = new();
|
||||||
|
|
||||||
|
private NullSecretResolver()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
+3
-1
@@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|||||||
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||||
/// protections cover it.
|
/// protections cover it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class OpcUaClientDriverOptions
|
// A record (not a plain class) so G-2b's OpcUaClientSecretResolution can produce a
|
||||||
|
// credential-resolved copy with a `with` expression — every property keeps its init setter.
|
||||||
|
public sealed record OpcUaClientDriverOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||||
|
/// (absent). Backs the driver's default (test/parse-only) construction path so callers that
|
||||||
|
/// never author a <c>secret:</c> credential ref need not thread a real resolver. A production
|
||||||
|
/// <c>OpcUaClientDriver</c> always receives the DI-registered resolver via the factory; a
|
||||||
|
/// <c>secret:</c> ref resolved against this null object throws fail-closed (the secret is
|
||||||
|
/// reported absent), which is the correct behaviour for a mis-wired deployment — the
|
||||||
|
/// <c>secret:</c> literal is never sent verbatim as a password.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <summary>The shared singleton instance.</summary>
|
||||||
|
public static readonly NullSecretResolver Instance = new();
|
||||||
|
|
||||||
|
private NullSecretResolver()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Opc.Ua;
|
|||||||
using Opc.Ua.Client;
|
using Opc.Ua.Client;
|
||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
@@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <param name="options">Driver configuration.</param>
|
/// <param name="options">Driver configuration.</param>
|
||||||
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
||||||
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
|
||||||
|
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
|
||||||
|
/// secret absent) for the test/parse-only path; the production factory threads the
|
||||||
|
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
|
||||||
|
/// </param>
|
||||||
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
||||||
ILogger<OpcUaClientDriver>? logger = null)
|
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly OpcUaClientDriverOptions _options;
|
private readonly OpcUaClientDriverOptions _options;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
// ---- IAlarmSource state ----
|
// ---- IAlarmSource state ----
|
||||||
|
|
||||||
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var candidates = ResolveEndpointCandidates(_options);
|
var candidates = ResolveEndpointCandidates(_options);
|
||||||
|
|
||||||
var identity = BuildUserIdentity(_options);
|
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
||||||
|
// shared secret store ONCE, here in the async connect flow, just before the credentials
|
||||||
|
// are consumed. _options stays the raw config (with secret: refs) — only this
|
||||||
|
// connect-scoped local carries plaintext, and only for the duration of the connect.
|
||||||
|
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
|
||||||
|
// Both credential consumers — the Username password below and the Certificate password
|
||||||
|
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
||||||
|
// single resolve covers both paths.
|
||||||
|
var resolvedOptions = await OpcUaClientSecretResolution
|
||||||
|
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var identity = BuildUserIdentity(resolvedOptions);
|
||||||
|
|
||||||
// Failover sweep: try each endpoint in order, return the session from the first
|
// Failover sweep: try each endpoint in order, return the session from the first
|
||||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||||
|
|||||||
+23
-5
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
Converters = { new JsonStringEnumConverter() },
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Register the OpcUaClient factory with the driver registry.</summary>
|
/// <summary>Register the OpcUaClient factory with the driver registry, threading the shared secret resolver.</summary>
|
||||||
/// <param name="registry">The driver factory registry to register with.</param>
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
||||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
/// <param name="secretResolver">
|
||||||
|
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of each
|
||||||
|
/// instance's <c>Password</c> / <c>UserCertificatePassword</c> at session-open. Defaults to
|
||||||
|
/// the <see cref="NullSecretResolver"/> null-object for the test/standalone path — callers
|
||||||
|
/// that need a working <c>secret:</c> credential ref must thread the DI resolver.
|
||||||
|
/// </param>
|
||||||
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ILoggerFactory? loggerFactory = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
var resolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, resolver));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional shared secret resolver for the driver's <c>secret:</c> credential arm; defaults
|
||||||
|
/// to the <see cref="NullSecretResolver"/> null-object (fail-closed on a <c>secret:</c> ref).
|
||||||
|
/// </param>
|
||||||
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
||||||
public static OpcUaClientDriver CreateInstance(
|
public static OpcUaClientDriver CreateInstance(
|
||||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null)
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
|
return new OpcUaClientDriver(
|
||||||
|
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||||
|
secretResolver ?? NullSecretResolver.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight
|
||||||
|
// (BuildMinimalAppConfig sets no user identity) and never reads opts.Password /
|
||||||
|
// opts.UserCertificatePassword. Those credentials — including any secret: refs — are
|
||||||
|
// therefore intentionally NOT resolved here; resolving them would be dead code. Secret
|
||||||
|
// resolution happens lazily in OpcUaClientDriver's async session-open path, where the
|
||||||
|
// credentials are actually consumed.
|
||||||
OpcUaClientDriverOptions? opts;
|
OpcUaClientDriverOptions? opts;
|
||||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves
|
||||||
|
/// <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/> and
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||||
|
/// <see cref="ISecretResolver"/> (the encrypted-at-rest store) just before the async
|
||||||
|
/// session-open path consumes them — retiring the cleartext password-in-DB model for
|
||||||
|
/// production.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Resolution is lazy (called from the connect flow, not at config deserialization) so a
|
||||||
|
/// full reconnect via <c>InitializeAsync</c> re-resolves and picks up secret rotations,
|
||||||
|
/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only <c>secret:</c>-prefixed
|
||||||
|
/// values are resolved; a null/empty or non-<c>secret:</c> value passes through verbatim so the
|
||||||
|
/// literal-password back-compat path is preserved. The <c>secret:</c> arm is <b>fail-closed</b>:
|
||||||
|
/// an absent/tombstoned secret throws rather than leaving the <c>secret:</c> literal in place,
|
||||||
|
/// which would otherwise be sent verbatim to the remote server as the password.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class OpcUaClientSecretResolution
|
||||||
|
{
|
||||||
|
private const string SecretPrefix = "secret:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved to their
|
||||||
|
/// plaintext via <paramref name="resolver"/>. Non-secret / null / empty fields are
|
||||||
|
/// returned unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async secret resolution.</param>
|
||||||
|
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||||
|
/// </exception>
|
||||||
|
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||||
|
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolver);
|
||||||
|
|
||||||
|
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var certPassword = await ResolveFieldAsync(
|
||||||
|
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Only re-materialize when something actually changed — a `with` on the reference-equal
|
||||||
|
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
|
||||||
|
if (ReferenceEquals(password, options.Password)
|
||||||
|
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||||
|
{
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
||||||
|
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
||||||
|
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
||||||
|
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async resolution.</param>
|
||||||
|
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
||||||
|
private static async Task<string?> ResolveFieldAsync(
|
||||||
|
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)
|
||||||
|
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = value[SecretPrefix.Length..];
|
||||||
|
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||||
|
return !string.IsNullOrEmpty(resolved)
|
||||||
|
? resolved
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||||
|
"absent from the store (fail-closed).");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -21,6 +21,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<HeadOutlet/>
|
<HeadOutlet/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes/>
|
<Routes AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })" />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<ThemeScripts />
|
<ThemeScripts />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||||
|
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||||
</NavRailSection>
|
</NavRailSection>
|
||||||
<NavRailSection Title="Scripting" Key="scripting">
|
<NavRailSection Title="Scripting" Key="scripting">
|
||||||
<NavRailItem Href="/scripts" Text="Scripts" />
|
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
@@ -10,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||||
|
using ZB.MOM.WW.Secrets.Ui;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||||
|
|
||||||
@@ -28,8 +30,13 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||||
// UseAuthentication() — see Program.cs.
|
// UseAuthentication() — see Program.cs.
|
||||||
|
// The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
|
||||||
|
// routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
|
||||||
|
// the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
|
||||||
|
// endpoint discovery. Both registrations are required or /admin/secrets 404s.
|
||||||
app.MapRazorComponents<TApp>()
|
app.MapRazorComponents<TApp>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode()
|
||||||
|
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +50,15 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||||
services.AddOtOpcUaDriverStatusServices();
|
services.AddOtOpcUaDriverStatusServices();
|
||||||
|
|
||||||
|
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
||||||
|
// "secrets:manage"/"secrets:reveal" authorization policies additively onto the AuthorizationOptions
|
||||||
|
// that AddOtOpcUaAuth (called just before AddAdminUI on admin nodes) sets up — Configure<T> stacks,
|
||||||
|
// so this ADDS the two policies without disturbing FleetAdmin/DriverOperator/ConfigEditor. The
|
||||||
|
// policies' AdminRole = "Administrator" reads the same ClaimTypes.Role claim as FleetAdmin, so an
|
||||||
|
// existing Administrator satisfies them with no new group→role mapping. Registered here (the AdminUI
|
||||||
|
// composition layer that already references the RCL) rather than in the core Security lib.
|
||||||
|
services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
||||||
|
|
||||||
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
||||||
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
||||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
||||||
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forces this node's secret-replication actor into existence at startup.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The replication actor is created <b>lazily</b>, on the first resolution of
|
||||||
|
/// <see cref="ISecretStore"/> — resolving the store builds <c>ReplicatingSecretStore</c>,
|
||||||
|
/// which takes <c>ISecretReplicator</c>, whose factory touches
|
||||||
|
/// <c>SecretReplicationActorProvider.ActorRef</c> and thereby spawns the actor.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Laziness is correct for the library (the <c>ActorSystem</c> is usually registered after
|
||||||
|
/// the replication call), but it makes participation in anti-entropy accidental: a node that
|
||||||
|
/// happens never to read or write a secret — a plausible steady state for a driver-role node
|
||||||
|
/// whose driver configs carry no <c>${secret:}</c> references — would never create the actor,
|
||||||
|
/// never announce its manifest, and never converge. Nothing would fail; it would just
|
||||||
|
/// silently not replicate. Resolving the store once at startup makes participation
|
||||||
|
/// unconditional.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Known ineffective against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0.</b> That
|
||||||
|
/// version never binds its own <c>ISecretReplicator</c>:
|
||||||
|
/// <c>AddZbSecretsAkkaReplication</c> calls <c>AddZbSecrets</c> first, which
|
||||||
|
/// <c>TryAdd</c>s <c>NoOpSecretReplicator</c>, making the package's own subsequent
|
||||||
|
/// <c>TryAddSingleton<ISecretReplicator></c> a no-op. Resolving the store therefore
|
||||||
|
/// builds a <c>ReplicatingSecretStore</c> around a no-op replicator and spawns no actor.
|
||||||
|
/// This hook is correct and stays in place for when the library is fixed, but replication
|
||||||
|
/// must be treated as <b>non-functional</b> until then — see the skipped test
|
||||||
|
/// <c>SecretsReplicationRegistrationTests.The_startup_hook_actually_creates_the_replication_actor</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="services">Root provider used to resolve the (decorated) secret store exactly once.</param>
|
||||||
|
public sealed class SecretReplicationStarter(IServiceProvider services) : IHostedService
|
||||||
|
{
|
||||||
|
/// <summary>Resolves <see cref="ISecretStore"/>, which spawns the replication actor.</summary>
|
||||||
|
/// <param name="cancellationToken">Unused — resolution is synchronous and non-blocking.</param>
|
||||||
|
/// <returns>A completed task.</returns>
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// The resolution itself is the side effect; the instance is deliberately unused.
|
||||||
|
_ = services.GetRequiredService<ISecretStore>();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No-op: the actor's lifetime is the actor system's.</summary>
|
||||||
|
/// <param name="cancellationToken">Unused.</param>
|
||||||
|
/// <returns>A completed task.</returns>
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single registration point for the secrets subsystem on every OtOpcUa node, admin- and
|
||||||
|
/// driver-role alike. Exists as a named extension rather than an inline <c>AddZbSecrets</c> call
|
||||||
|
/// in <c>Program.cs</c> so the choice of <c>ISecretStore</c> implementation is covered by a DI
|
||||||
|
/// resolution test — <c>Program.cs</c> is top-level statements and cannot be exercised directly,
|
||||||
|
/// which is exactly how a "registered but never resolvable" wiring defect ships unnoticed.
|
||||||
|
/// </summary>
|
||||||
|
public static class SecretsRegistration
|
||||||
|
{
|
||||||
|
/// <summary>Configuration key gating cluster secret replication. Absent or false = off.</summary>
|
||||||
|
public const string ReplicationEnabledKey = "Secrets:Replication:Enabled";
|
||||||
|
|
||||||
|
/// <summary>Configuration section holding <c>AkkaSecretsReplicationOptions</c>.</summary>
|
||||||
|
public const string ReplicationSectionPath = "Secrets:Replication";
|
||||||
|
|
||||||
|
/// <summary>Configuration section holding the core secrets options.</summary>
|
||||||
|
public const string SecretsSectionPath = "Secrets";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when cluster secret replication is switched on in configuration. Defaults to false —
|
||||||
|
/// absent configuration must mean "off".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuration">The application configuration.</param>
|
||||||
|
/// <returns><c>true</c> when replication is enabled.</returns>
|
||||||
|
public static bool IsReplicationEnabled(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
return configuration.GetValue(ReplicationEnabledKey, defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the secrets subsystem. Replication is <b>opt-in</b>: unless
|
||||||
|
/// <see cref="ReplicationEnabledKey"/> is true this is exactly the plain local SQLite wiring
|
||||||
|
/// the host has always used.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The gate is deliberately default-deny. This call decides which <c>ISecretStore</c> the
|
||||||
|
/// container hands to <b>every</b> node, including driver-role nodes with no auth or
|
||||||
|
/// AdminUI surface, where a bad store surfaces as drivers failing to open sessions rather
|
||||||
|
/// than as a failing test. Enabling replication by default would change secret resolution
|
||||||
|
/// across a production fleet with nobody having asked for it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>AddZbSecretsAkkaReplication</c> calls <c>AddZbSecrets</c> internally, so the two
|
||||||
|
/// branches are alternatives, never additive — calling both would double-register.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="services">The service collection to add to.</param>
|
||||||
|
/// <param name="configuration">The application configuration.</param>
|
||||||
|
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||||
|
public static IServiceCollection AddOtOpcUaSecrets(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
|
||||||
|
if (!IsReplicationEnabled(configuration))
|
||||||
|
{
|
||||||
|
services.AddZbSecrets(configuration, SecretsSectionPath);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddZbSecretsAkkaReplication(configuration, SecretsSectionPath, ReplicationSectionPath);
|
||||||
|
|
||||||
|
// Anti-entropy participation must not hinge on this node happening to touch a secret — see
|
||||||
|
// SecretReplicationStarter for why the library's lazy actor creation is insufficient here.
|
||||||
|
services.AddSingleton<SecretReplicationStarter>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<SecretReplicationStarter>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
|
|
||||||
@@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap
|
|||||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||||
Register(registry, loggerFactory, scriptRoot);
|
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||||
|
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||||
|
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||||
|
Register(registry, loggerFactory, secretResolver, scriptRoot);
|
||||||
return registry;
|
return registry;
|
||||||
});
|
});
|
||||||
services.AddSingleton<IDriverFactory>(sp =>
|
services.AddSingleton<IDriverFactory>(sp =>
|
||||||
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
|
|||||||
private static void Register(
|
private static void Register(
|
||||||
DriverFactoryRegistry registry,
|
DriverFactoryRegistry registry,
|
||||||
ILoggerFactory? loggerFactory,
|
ILoggerFactory? loggerFactory,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||||
{
|
{
|
||||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|||||||
using ZB.MOM.WW.Configuration;
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.Telemetry.Serilog;
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Configuration;
|
||||||
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||||
@@ -72,6 +78,27 @@ if (roleSuffix is not null)
|
|||||||
builder.Configuration.AddCommandLine(args);
|
builder.Configuration.AddCommandLine(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-host ${secret:} expansion: rewrites ${secret:name} tokens in the assembled configuration
|
||||||
|
// BEFORE the host is built, so every downstream binder/validator sees resolved plaintext.
|
||||||
|
// Placement matters: this runs AFTER all config providers are assembled (base appsettings, the
|
||||||
|
// role overlay, and the env-vars + command-line re-append above) but BEFORE anything READS a
|
||||||
|
// config value (AddZbSerilog's ReadFrom.Configuration, AddOtOpcUaConfigDb, and the first
|
||||||
|
// ValidateOnStart bind all follow). With no ${secret:} tokens present this is a no-op pass;
|
||||||
|
// it also migrates the secrets SQLite store on startup.
|
||||||
|
// ASP0000: DELIBERATE throwaway container — expansion must run before builder.Build(), so it
|
||||||
|
// cannot use the app's DI. Fully disposed here; shares no singletons with the host container.
|
||||||
|
#pragma warning disable ASP0000
|
||||||
|
await using (var secretsProvider = new ServiceCollection()
|
||||||
|
.AddZbSecrets(builder.Configuration, "Secrets")
|
||||||
|
.BuildServiceProvider())
|
||||||
|
#pragma warning restore ASP0000
|
||||||
|
{
|
||||||
|
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
|
||||||
|
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
|
||||||
|
await new SecretReferenceExpander(resolver)
|
||||||
|
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
|
||||||
|
}
|
||||||
|
|
||||||
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
|
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
|
||||||
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
|
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
|
||||||
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
|
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
|
||||||
@@ -287,6 +314,14 @@ if (hasDriver)
|
|||||||
builder.Services.AddAkka("otopcua", (ab, sp) =>
|
builder.Services.AddAkka("otopcua", (ab, sp) =>
|
||||||
{
|
{
|
||||||
ab.WithOtOpcUaClusterBootstrap(sp);
|
ab.WithOtOpcUaClusterBootstrap(sp);
|
||||||
|
// Secret-replication wire protocol → its own serializer, merged only when replication is on so a
|
||||||
|
// non-replicating node carries no serializer bindings for messages it will never see. Appended
|
||||||
|
// (Akka.Hosting's fallback merge — the same mode WithOtOpcUaClusterBootstrap uses for the base
|
||||||
|
// config) rather than a raw Config.WithFallback, which would fight the builder's own assembly.
|
||||||
|
// Without this the protocol DTOs would round-trip on Akka's default JSON serializer, leaving the
|
||||||
|
// serializer that carries secret ciphertext an inherited default rather than an explicit choice.
|
||||||
|
if (SecretsRegistration.IsReplicationEnabled(builder.Configuration))
|
||||||
|
ab.AddHocon(AkkaSecretsReplication.SerializationConfig, HoconAddMode.Append);
|
||||||
if (hasAdmin)
|
if (hasAdmin)
|
||||||
{
|
{
|
||||||
ab.WithOtOpcUaControlPlaneSingletons();
|
ab.WithOtOpcUaControlPlaneSingletons();
|
||||||
@@ -323,6 +358,10 @@ if (hasAdmin)
|
|||||||
builder.Services.AddOtOpcUaAdminClients();
|
builder.Services.AddOtOpcUaAdminClients();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
|
||||||
|
// Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration.
|
||||||
|
builder.Services.AddOtOpcUaSecrets(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddOtOpcUaHealth();
|
builder.Services.AddOtOpcUaHealth();
|
||||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -11,12 +11,27 @@
|
|||||||
"DisableLogin": false
|
"DisableLogin": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "otopcua-secrets.db",
|
||||||
|
"MasterKey": {
|
||||||
|
"Source": "Environment",
|
||||||
|
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
||||||
|
},
|
||||||
|
"RunMigrationsOnStartup": true,
|
||||||
|
"ResolveCacheTtl": "00:00:30",
|
||||||
|
"Replication": {
|
||||||
|
"_comment": "Peer-to-peer secret replication over the Akka cluster. OPT-IN: Enabled=false keeps the plain local SQLite store on every node. KNOWN NON-FUNCTIONAL against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 — that version's ISecretReplicator registration is shadowed by the NoOp one AddZbSecrets registers first, so enabling this decorates the store but replicates nothing and starts no actor. Do not enable until the library is fixed. All nodes must also share the same KEK.",
|
||||||
|
"Enabled": false,
|
||||||
|
"AnnounceInterval": "00:00:30",
|
||||||
|
"ActorName": "zb-secret-replication"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ServerHistorian": {
|
"ServerHistorian": {
|
||||||
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
|
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
"_ApiKeyComment": "NEVER commit a real key. Supply via the environment variable ServerHistorian__ApiKey.",
|
"_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.",
|
||||||
"UseTls": true,
|
"UseTls": true,
|
||||||
"AllowUntrustedServerCertificate": false,
|
"AllowUntrustedServerCertificate": false,
|
||||||
"CaCertificatePath": null,
|
"CaCertificatePath": null,
|
||||||
|
|||||||
@@ -497,6 +497,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
|
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
|
||||||
condition.Message.Value = new LocalizedText(state.Message);
|
condition.Message.Value = new LocalizedText(state.Message);
|
||||||
|
|
||||||
|
// #477 — project the source-data quality. A Good→Bad bucket change is a genuine condition
|
||||||
|
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
|
||||||
|
// it never touches Active/Acked/Retain (annotation only). Quality is an optional Part 9 child —
|
||||||
|
// null-guard it like Confirmed/Shelving in case a leaner SDK child set omits it.
|
||||||
|
if (condition.Quality is not null)
|
||||||
|
{
|
||||||
|
condition.Quality.Value = StatusFromQuality(state.Quality);
|
||||||
|
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
|
||||||
|
}
|
||||||
|
|
||||||
// Part 9: retain the condition while it is active OR unacknowledged so a client's
|
// Part 9: retain the condition while it is active OR unacknowledged so a client's
|
||||||
// ConditionRefresh replays it. The event firing below also depends on this Retain being
|
// ConditionRefresh replays it. The event firing below also depends on this Retain being
|
||||||
// correct (a non-retained inactive+acked condition still fires its transition event, but
|
// correct (a non-retained inactive+acked condition still fires its transition event, but
|
||||||
@@ -535,6 +545,50 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND
|
||||||
|
/// from any alarm transition. Used by the driver-connectivity path (comms lost → Bad, restored →
|
||||||
|
/// Good) so a native condition whose device is unreachable stops reporting the accidentally-Good
|
||||||
|
/// default. This sets ONLY <see cref="ConditionState.Quality"/> — it never touches
|
||||||
|
/// Active / Acked / Severity / Retain (a comms-lost active alarm must stay active). A change in the
|
||||||
|
/// quality bucket is a genuine Part 9 condition change, so it fires one condition event carrying the
|
||||||
|
/// new Quality; an unchanged bucket suppresses (no spurious event). Unknown / unmaterialised node ⇒
|
||||||
|
/// safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||||
|
/// <param name="quality">The source-data quality to annotate.</param>
|
||||||
|
/// <param name="sourceTimestampUtc">Timestamp of the connectivity transition in UTC.</param>
|
||||||
|
/// <param name="realm">The condition's address-space realm.</param>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||||||
|
EnsureAddressSpaceCreated();
|
||||||
|
|
||||||
|
var key = MapKey(realm, alarmNodeId);
|
||||||
|
lock (Lock)
|
||||||
|
{
|
||||||
|
// Only materialised conditions carry a Quality child; a bare value variable or a missing node is
|
||||||
|
// a no-op (the connectivity fan-out visits every condition the driver owns, some of which a
|
||||||
|
// concurrent rebuild may have just cleared).
|
||||||
|
if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var newCode = StatusFromQuality(quality);
|
||||||
|
var changed = condition.Quality.Value.Code != newCode.Code;
|
||||||
|
|
||||||
|
condition.Quality.Value = newCode;
|
||||||
|
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
|
||||||
|
|
||||||
|
// Fire ONLY on a real bucket change so a steady-state connectivity re-assert doesn't spam events.
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
condition.Time.Value = sourceTimestampUtc;
|
||||||
|
condition.ReceiveTime.Value = sourceTimestampUtc;
|
||||||
|
ReportConditionEvent(condition, sourceTimestampUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
|
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
|
||||||
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
|
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
|
||||||
@@ -650,7 +704,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
bool Enabled,
|
bool Enabled,
|
||||||
AlarmShelvingKind Shelving,
|
AlarmShelvingKind Shelving,
|
||||||
ushort MappedSeverity,
|
ushort MappedSeverity,
|
||||||
string Message);
|
string Message,
|
||||||
|
// #477 — the source-data quality bucket. Included so a quality-only transition (e.g. a device
|
||||||
|
// going comms-lost: Good→Bad with no state change) is a genuine delta and fires a Part 9 event.
|
||||||
|
// StatusCode has value equality, so the record struct's == still holds.
|
||||||
|
StatusCode Quality);
|
||||||
|
|
||||||
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
|
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
|
||||||
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
|
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
|
||||||
@@ -676,7 +734,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
Enabled: condition.EnabledState?.Id?.Value ?? true,
|
Enabled: condition.EnabledState?.Id?.Value ?? true,
|
||||||
Shelving: ReadShelvingKind(condition),
|
Shelving: ReadShelvingKind(condition),
|
||||||
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
|
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
|
||||||
Message: condition.Message?.Value?.Text ?? string.Empty);
|
Message: condition.Message?.Value?.Text ?? string.Empty,
|
||||||
|
// Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default
|
||||||
|
// and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it.
|
||||||
|
Quality: condition.Quality?.Value ?? StatusCodes.Good);
|
||||||
|
|
||||||
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
|
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
|
||||||
/// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
|
/// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
|
||||||
@@ -694,7 +755,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// node's read-back default (Unshelved).
|
// node's read-back default (Unshelved).
|
||||||
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
|
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
|
||||||
MappedSeverity: (ushort)MapSeverity(state.Severity),
|
MappedSeverity: (ushort)MapSeverity(state.Severity),
|
||||||
Message: state.Message ?? string.Empty);
|
Message: state.Message ?? string.Empty,
|
||||||
|
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
|
||||||
|
// node's read-back default (Good) so a snapshot Quality can't register a spurious delta.
|
||||||
|
Quality: condition.Quality is not null ? StatusFromQuality(state.Quality) : StatusCodes.Good);
|
||||||
|
|
||||||
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
|
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
|
||||||
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
|
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
|
||||||
@@ -799,6 +863,48 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
||||||
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
||||||
|
|
||||||
|
// #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
|
||||||
|
// the type's embedded definition but leaves them UNSET (the init string declares them mandatory
|
||||||
|
// with NO default), and nothing downstream fills them in: the auto-filling
|
||||||
|
// BaseEventState.Initialize(context, source, severity, message) overload is only used for
|
||||||
|
// transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
|
||||||
|
// here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
|
||||||
|
// SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
|
||||||
|
// alarm-bearing raw tag materialises ONLY this condition, with no sibling value
|
||||||
|
// variable (see AddressSpaceApplier's alarm branch), so there is no other node.
|
||||||
|
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
|
||||||
|
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
|
||||||
|
// is already carried by ConditionName below. See docs/AlarmTracking.md.
|
||||||
|
alarm.EventType.Value = alarm.TypeDefinitionId;
|
||||||
|
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
|
||||||
|
alarm.SourceName.Value = alarmNodeId;
|
||||||
|
|
||||||
|
// #475 — the mandatory ConditionType classification fields, unset by Create() for the same reason as
|
||||||
|
// the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
|
||||||
|
// text on the wire, which buckets every alarm as unclassified in a Part 9 HMI.
|
||||||
|
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
|
||||||
|
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
|
||||||
|
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
|
||||||
|
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
|
||||||
|
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
|
||||||
|
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
|
||||||
|
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
|
||||||
|
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
|
||||||
|
|
||||||
|
// #477 — ConditionType.Quality (the quality of the condition's source data). Create() leaves it
|
||||||
|
// UNSET, and default(StatusCode) == StatusCodes.Good (0x0), so an unassigned Quality reports Good
|
||||||
|
// unconditionally — a wrong VALUE (not a null), which hides a comms-lost source. A NATIVE condition
|
||||||
|
// has no data yet at materialise (its driver hasn't confirmed connectivity), so it starts
|
||||||
|
// BadWaitingForInitialData — the same "no driver data yet" convention value variables use — and is
|
||||||
|
// driven Good by the driver-connectivity path (DriverHostActor → ProjectQuality) once Connected. A
|
||||||
|
// SCRIPTED condition is script-computed and always live in v1, so it starts Good. Quality is a pure
|
||||||
|
// annotation: it NEVER alters Active/Acked/Retain (a comms-lost active alarm must stay active).
|
||||||
|
if (alarm.Quality is not null)
|
||||||
|
{
|
||||||
|
alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good;
|
||||||
|
if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
||||||
alarm.SetEnableState(SystemContext, true);
|
alarm.SetEnableState(SystemContext, true);
|
||||||
alarm.SetActiveState(SystemContext, false);
|
alarm.SetActiveState(SystemContext, false);
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
|||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
|
=> _nodeManager.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||||
|
|||||||
@@ -570,6 +570,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
|
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
@@ -600,6 +601,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
|
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
@@ -1010,6 +1012,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
/// signal the inbound-write gate uses — only the Primary publishes the single fleet-wide copy.
|
/// signal the inbound-write gate uses — only the Primary publishes the single fleet-wide copy.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native
|
||||||
|
/// alarm condition the driver owns: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||||
|
/// <see cref="OpcUaQuality.Good"/>. This is the ONLY signal for a comms-lost native source, because a
|
||||||
|
/// disconnected driver emits no alarm transitions — the alarm feed goes silent, so without this a
|
||||||
|
/// comms-lost condition would keep reporting the accidentally-Good default forever.
|
||||||
|
/// <para>
|
||||||
|
/// UNGATED by redundancy role (like the condition write in <see cref="ForwardNativeAlarm"/>): a
|
||||||
|
/// Secondary keeps its address space — including condition quality — warm for failover. Quality is
|
||||||
|
/// a pure annotation: <c>WriteAlarmQuality</c> touches ONLY the condition's Quality, never its
|
||||||
|
/// Active/Acked/Retain, and fires a Part 9 event only on a real quality-bucket change. No cluster
|
||||||
|
/// <c>alerts</c> row is published here — driver comms health has its own status surface
|
||||||
|
/// (<see cref="IDriverHealthPublisher"/>); a row per condition would be alarm-fatigue.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
|
||||||
|
{
|
||||||
|
if (_opcUaPublishActor is null) return;
|
||||||
|
|
||||||
|
var quality = msg.Connected ? OpcUaQuality.Good : OpcUaQuality.Bad;
|
||||||
|
var ts = DateTime.UtcNow;
|
||||||
|
var annotated = 0;
|
||||||
|
// Fan out to every condition this driver owns. _alarmNodeIdByDriverRef is keyed by
|
||||||
|
// (DriverInstanceId, RawPath); one driver ref can back several condition NodeIds (identical machines).
|
||||||
|
foreach (var ((driverId, _), nodeIds) in _alarmNodeIdByDriverRef)
|
||||||
|
{
|
||||||
|
if (driverId != msg.DriverInstanceId) continue;
|
||||||
|
foreach (var n in nodeIds)
|
||||||
|
{
|
||||||
|
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
|
||||||
|
n.NodeId, quality, ts, n.Realm));
|
||||||
|
annotated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annotated > 0)
|
||||||
|
_log.Debug("DriverHost {Node}: driver {Driver} {State} — annotated {Count} native condition(s) {Quality}",
|
||||||
|
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, quality);
|
||||||
|
}
|
||||||
|
|
||||||
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
|
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
|
||||||
{
|
{
|
||||||
if (_opcUaPublishActor is null) return;
|
if (_opcUaPublishActor is null) return;
|
||||||
@@ -1326,6 +1368,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
|
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
|
||||||
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
|
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
|
||||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
|
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
|
||||||
|
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
|
||||||
|
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
|
||||||
|
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
||||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
||||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
public sealed record InitializeSucceeded(int Generation);
|
public sealed record InitializeSucceeded(int Generation);
|
||||||
public sealed record InitializeFailed(string Reason, int Generation);
|
public sealed record InitializeFailed(string Reason, int Generation);
|
||||||
public sealed record DisconnectObserved(string Reason);
|
public sealed record DisconnectObserved(string Reason);
|
||||||
|
/// <summary>#477 — sent to the parent (<see cref="DriverHostActor"/>) on every connectivity transition:
|
||||||
|
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
|
||||||
|
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
|
||||||
|
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
|
||||||
|
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
|
||||||
|
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
||||||
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>
|
||||||
@@ -413,6 +419,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
private void Connected()
|
private void Connected()
|
||||||
{
|
{
|
||||||
|
// #477 — announce connectivity to the host so it can clear any comms-lost Quality annotation on this
|
||||||
|
// driver's native alarm conditions (Bad → Good). Fire-and-forget; the host defaults conditions to a
|
||||||
|
// non-Good "waiting for initial data" quality at materialise, and this is what confirms them Good.
|
||||||
|
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: true));
|
||||||
|
|
||||||
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
|
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
|
||||||
Receive<DisconnectObserved>(msg =>
|
Receive<DisconnectObserved>(msg =>
|
||||||
{
|
{
|
||||||
@@ -483,6 +494,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
private void Reconnecting()
|
private void Reconnecting()
|
||||||
{
|
{
|
||||||
|
// #477 — announce comms loss to the host so it annotates this driver's native alarm conditions Bad
|
||||||
|
// (a comms-lost driver emits no alarm events, so this is the ONLY signal that the source is unreachable).
|
||||||
|
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: false));
|
||||||
|
|
||||||
Receive<RetryConnect>(_ => InitializeAsync(_currentConfigJson ?? "{}"));
|
Receive<RetryConnect>(_ => InitializeAsync(_currentConfigJson ?? "{}"));
|
||||||
// Fast-fail writes while reconnecting (same reason as Connecting — avoids the 8s host Ask
|
// Fast-fail writes while reconnecting (same reason as Connecting — avoids the 8s host Ask
|
||||||
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
|
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The peppered-HMAC API key (<c>histgw_<id>_<secret></c>) the gateway validates
|
/// The peppered-HMAC API key (<c>histgw_<id>_<secret></c>) the gateway validates
|
||||||
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
|
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
|
||||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when
|
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
|
||||||
/// <see cref="Enabled"/> is <c>true</c>.
|
/// key or a <c>${secret:otopcua/historian/api-key}</c> token, which the pre-host secrets
|
||||||
|
/// expander resolves fail-closed (throwing if the secret is absent) before this options
|
||||||
|
/// class binds. Required when <see cref="Enabled"/> is <c>true</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ApiKey { get; init; } = "";
|
public string ApiKey { get; init; } = "";
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
|
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
|
||||||
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
|
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
|
||||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||||
|
/// <summary>#477 — annotate a materialised condition's source-data Quality out of band from any alarm
|
||||||
|
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||||
|
/// <see cref="OpcUaQuality.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
|
||||||
|
/// which sets ONLY Quality and fires one Part 9 event on a quality-bucket change.</summary>
|
||||||
|
/// <param name="AlarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||||
|
/// <param name="Quality">The source-data quality to annotate.</param>
|
||||||
|
/// <param name="TimestampUtc">The connectivity transition timestamp in UTC.</param>
|
||||||
|
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
|
||||||
|
public sealed record AlarmQualityUpdate(string AlarmNodeId, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||||
@@ -239,6 +248,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
|
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
|
||||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||||
|
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||||
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
||||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||||
@@ -296,6 +306,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||||
|
Interlocked.Increment(ref _writes);
|
||||||
|
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmQuality threw for {Node}", msg.AlarmNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleRebuild(RebuildAddressSpace msg)
|
private void HandleRebuild(RebuildAddressSpace msg)
|
||||||
{
|
{
|
||||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||||
|
|||||||
@@ -278,11 +278,31 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
|||||||
|
|
||||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||||
{
|
{
|
||||||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
|
||||||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
// condition's worst-of-input quality; a Bad/Uncertain input is no longer silently treated as Good.
|
||||||
|
_upstream.Push(msg.TagId,
|
||||||
|
new DataValueSnapshot(msg.Value, StatusFromQuality(msg.Quality), msg.TimestampUtc, msg.TimestampUtc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
|
||||||
|
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
|
||||||
|
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||||
|
{
|
||||||
|
OpcUaQuality.Bad => 0x80000000u,
|
||||||
|
OpcUaQuality.Uncertain => 0x40000000u,
|
||||||
|
_ => 0u, // Good
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
|
||||||
|
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
|
||||||
|
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
|
||||||
|
{
|
||||||
|
0 => OpcUaQuality.Good,
|
||||||
|
1 => OpcUaQuality.Uncertain,
|
||||||
|
_ => OpcUaQuality.Bad,
|
||||||
|
};
|
||||||
|
|
||||||
private void OnEngineEmission(EngineEmission msg)
|
private void OnEngineEmission(EngineEmission msg)
|
||||||
{
|
{
|
||||||
var e = msg.Event;
|
var e = msg.Event;
|
||||||
@@ -294,6 +314,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #478 — QualityChanged is a source-quality annotation (worst-of-input bucket moved with no Part 9
|
||||||
|
// state transition — e.g. an input went Bad, freezing the condition). Route it to the dedicated
|
||||||
|
// WriteAlarmQuality node path (sets ONLY Quality, one Part 9 event on a bucket change): NO full-state
|
||||||
|
// projection (would clobber severity/message) and NO /alerts row (it is not a state transition, so it
|
||||||
|
// must not historize). Same out-of-band quality path native comms-loss uses (#477 Layer 2).
|
||||||
|
if (e.Emission == EmissionKind.QualityChanged)
|
||||||
|
{
|
||||||
|
_publishActor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
||||||
|
AlarmNodeId: e.AlarmId,
|
||||||
|
Quality: QualityFromStatus(e.WorstInputStatusCode),
|
||||||
|
TimestampUtc: e.TimestampUtc,
|
||||||
|
Realm: AddressSpaceRealm.Uns));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
|
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
|
||||||
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
|
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
|
||||||
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
|
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
|
||||||
@@ -539,7 +574,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
|||||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||||
Severity: (ushort)SeverityToInt(e.Severity),
|
Severity: (ushort)SeverityToInt(e.Severity),
|
||||||
Message: e.Message);
|
Message: e.Message,
|
||||||
|
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||||
|
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||||
|
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||||
|
Quality: QualityFromStatus(e.WorstInputStatusCode));
|
||||||
|
|
||||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor
|
|||||||
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc);
|
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.Quality);
|
||||||
foreach (var sub in subscribers)
|
foreach (var sub in subscribers)
|
||||||
{
|
{
|
||||||
sub.Tell(dep);
|
sub.Tell(dep);
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
public const string ScriptLogsTopic = "script-logs";
|
public const string ScriptLogsTopic = "script-logs";
|
||||||
|
|
||||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
/// <summary>A dependency's value changed. <paramref name="Quality"/> (#478) carries the source
|
||||||
|
/// driver's OPC UA quality so the scripted-alarm host can derive a condition's worst-of-input quality;
|
||||||
|
/// it defaults to <see cref="OpcUaQuality.Good"/>, and the virtual-tag engine ignores it.</summary>
|
||||||
|
public sealed record DependencyValueChanged(
|
||||||
|
string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good);
|
||||||
|
|
||||||
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
||||||
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> WriteValueCalled = true;
|
=> WriteValueCalled = true;
|
||||||
|
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
@@ -226,6 +228,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
public bool FolderRenameCalled { get; private set; }
|
public bool FolderRenameCalled { get; private set; }
|
||||||
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
|||||||
@@ -1005,6 +1005,104 @@ public sealed class ScriptedAlarmEngineTests
|
|||||||
// If Dispose threw or hung, the WaitAsync would surface it.
|
// If Dispose threw or hung, the WaitAsync would surface it.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #478 — scripted condition Quality from worst-of-input tag quality (Layer 3)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private const uint StatusUncertain = 0x40000000u;
|
||||||
|
private const uint StatusBad = 0x80000000u;
|
||||||
|
|
||||||
|
/// <summary>A transition that fires while an input is Uncertain carries that worst input quality on the
|
||||||
|
/// emitted event (so the projected snapshot doesn't clobber quality back to Good).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Transition_carries_worst_input_quality()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
// Uncertain is "ready" (only outright Bad short-circuits), so the predicate runs and fires Activated.
|
||||||
|
up.Push("Temp", 150, StatusUncertain);
|
||||||
|
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
|
||||||
|
|
||||||
|
var activated = events.First(e => e.Emission == EmissionKind.Activated);
|
||||||
|
(activated.WorstInputStatusCode >> 30).ShouldBe(1u); // Uncertain severity bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A Bad input freezes the condition (no transition) but the worst-quality bucket changed
|
||||||
|
/// Good→Bad, so the engine emits a standalone QualityChanged carrying Bad.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Bad_input_without_transition_emits_QualityChanged_Bad()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50); // predicate false → inactive
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
up.Push("Temp", 50, StatusBad);
|
||||||
|
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
|
||||||
|
|
||||||
|
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
|
||||||
|
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u); // Bad severity bucket
|
||||||
|
// No phantom state transition accompanied the quality change.
|
||||||
|
events.Any(e => e.Emission == EmissionKind.Activated || e.Emission == EmissionKind.Cleared)
|
||||||
|
.ShouldBeFalse();
|
||||||
|
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Restoring_good_input_emits_QualityChanged_Good()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
up.Push("Temp", 50, StatusBad);
|
||||||
|
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged
|
||||||
|
&& (e.WorstInputStatusCode >> 30) >= 2u));
|
||||||
|
events.Clear();
|
||||||
|
|
||||||
|
up.Push("Temp", 50, 0u); // Good again, still below threshold
|
||||||
|
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
|
||||||
|
|
||||||
|
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
|
||||||
|
(q.WorstInputStatusCode >> 30).ShouldBe(0u); // Good bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task No_QualityChanged_when_bucket_unchanged()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
up.Push("Temp", 60, 0u); // Good→Good, still below threshold → no transition, no quality change
|
||||||
|
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
|
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||||
{
|
{
|
||||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||||
|
|||||||
@@ -107,6 +107,28 @@ public sealed class ScriptedAlarmSourceTests
|
|||||||
events.Count.ShouldBe(0);
|
events.Count.ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — a QualityChanged engine emission (source-quality bucket change, no state transition)
|
||||||
|
/// must NOT surface through the IAlarmSource fan-out: quality is delivered out of band via the
|
||||||
|
/// dedicated node path, never as an AlarmEventArgs (which would materialize / historize a condition).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task QualityChanged_emission_raises_no_alarm_event()
|
||||||
|
{
|
||||||
|
var (engine, source, up) = await BuildAsync();
|
||||||
|
using var _e = engine;
|
||||||
|
using var _s = source;
|
||||||
|
|
||||||
|
var events = new List<AlarmEventArgs>();
|
||||||
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||||
|
await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Drive HighTemp's input Bad: the predicate freezes (no transition), but the worst-quality bucket
|
||||||
|
// moves Good→Bad → the engine emits QualityChanged. The source must swallow it.
|
||||||
|
up.Push("Temp", 50, 0x80000000u);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
events.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,6 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
||||||
|
|
||||||
@@ -14,7 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
|||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class GalaxyDriverBrowserTests
|
public sealed class GalaxyDriverBrowserTests
|
||||||
{
|
{
|
||||||
private readonly GalaxyDriverBrowser _sut = new();
|
// These unit tests exercise only the pre-connect validation paths (empty endpoint,
|
||||||
|
// blank client name) that throw BEFORE the API-key secret: arm is resolved, so a
|
||||||
|
// null-returning stub resolver suffices — it is never consulted.
|
||||||
|
private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver());
|
||||||
|
|
||||||
|
/// <summary>A no-op resolver: every name resolves to null. Never consulted by these tests.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
||||||
/// so the factory wire-up picks the right browser implementation.</summary>
|
/// so the factory wire-up picks the right browser implementation.</summary>
|
||||||
|
|||||||
+94
-27
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Follow-up #2 — pins the three resolution forms supported by
|
/// Follow-up #2 + G-2a — pins the five resolution forms supported by
|
||||||
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||||
/// and the literal-string fallback. A future DPAPI arm slots in here without
|
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
|
||||||
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
|
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
|
||||||
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
|
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
|
||||||
/// driver and the AdminUI browser share one copy.)
|
/// <c>GalaxyDriver</c> to the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the
|
||||||
|
/// runtime driver and the AdminUI browser share one copy.)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GalaxyDriverApiKeyResolverTests
|
public sealed class GalaxyDriverApiKeyResolverTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Name the fake resolver knows; matches the SecretName-normalized form.</summary>
|
||||||
|
private const string KnownSecretName = "galaxy/inst1/apikey";
|
||||||
|
|
||||||
|
/// <summary>The value the fake resolver returns for <see cref="KnownSecretName"/>.</summary>
|
||||||
|
private const string KnownSecretValue = "key-from-secret-store";
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns a known value for one known name, null otherwise.</summary>
|
||||||
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||||
|
|
||||||
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Literal_string_is_returned_unchanged()
|
public async Task Literal_string_is_returned_unchanged()
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
|
(await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver()))
|
||||||
|
.ShouldBe("plain-text-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_resolves_to_environment_variable()
|
public async Task Env_prefix_resolves_to_environment_variable()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
||||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
(await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
|
||||||
|
.ShouldBe("key-from-env");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_unset_variable_throws_with_descriptive_message()
|
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
||||||
Environment.SetEnvironmentVariable(name, null);
|
Environment.SetEnvironmentVariable(name, null);
|
||||||
|
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
|
||||||
ex.Message.ShouldContain(name);
|
ex.Message.ShouldContain(name);
|
||||||
ex.Message.ShouldContain("unset");
|
ex.Message.ShouldContain("unset");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_resolves_to_trimmed_file_contents()
|
public async Task File_prefix_resolves_to_trimmed_file_contents()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
||||||
File.WriteAllText(path, " key-from-file \n");
|
File.WriteAllText(path, " key-from-file \n");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
|
||||||
|
.ShouldBe("key-from-file");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_missing_path_throws()
|
public async Task File_prefix_missing_path_throws()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||||
ex.Message.ShouldContain(path);
|
ex.Message.ShouldContain(path);
|
||||||
ex.Message.ShouldContain("doesn't exist");
|
ex.Message.ShouldContain("doesn't exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
|
||||||
|
|
||||||
|
/// <summary>Verifies that secret: prefix resolves through the ISecretResolver.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_resolves_through_secret_resolver()
|
||||||
|
{
|
||||||
|
(await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver()))
|
||||||
|
.ShouldBe(KnownSecretValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_absent_secret_throws_fail_closed()
|
||||||
|
{
|
||||||
|
const string missingRef = "secret:galaxy/missing/apikey";
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
|
GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver()));
|
||||||
|
|
||||||
|
// Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and
|
||||||
|
// must NOT return the ref string as the key.
|
||||||
|
ex.Message.ShouldContain("galaxy/missing/apikey");
|
||||||
|
ex.Message.ShouldContain("absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the secret: arm does not consult the logger's literal warning.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_does_not_emit_literal_warning()
|
||||||
|
{
|
||||||
|
var logger = new CaptureLogger();
|
||||||
|
await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger);
|
||||||
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
||||||
|
|
||||||
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Literal_string_emits_warning_when_logger_supplied()
|
public async Task Literal_string_emits_warning_when_logger_supplied()
|
||||||
{
|
{
|
||||||
// A literal API key on a production deployment means the cleartext key sits
|
// A literal API key on a production deployment means the cleartext key sits
|
||||||
// in the DriverConfig JSON. The resolver must surface a warning so an
|
// in the DriverConfig JSON. The resolver must surface a warning so an
|
||||||
// operator who committed one by accident sees it at startup.
|
// operator who committed one by accident sees it at startup.
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger);
|
||||||
|
|
||||||
key.ShouldBe("plain-text-key");
|
key.ShouldBe("plain-text-key");
|
||||||
logger.Entries.ShouldContain(e =>
|
logger.Entries.ShouldContain(e =>
|
||||||
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dev_prefix_returns_literal_without_warning()
|
public async Task Dev_prefix_returns_literal_without_warning()
|
||||||
{
|
{
|
||||||
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
||||||
// key (dev / parity rig). The resolver must accept it AND suppress the
|
// key (dev / parity rig). The resolver must accept it AND suppress the
|
||||||
// warning so production logs aren't polluted on a deliberate dev choice.
|
// warning so production logs aren't polluted on a deliberate dev choice.
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger);
|
||||||
|
|
||||||
key.ShouldBe("plain-text-key");
|
key.ShouldBe("plain-text-key");
|
||||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
@@ -111,14 +159,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_does_not_emit_literal_warning()
|
public async Task Env_prefix_does_not_emit_literal_warning()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
||||||
Environment.SetEnvironmentVariable(name, "v");
|
Environment.SetEnvironmentVariable(name, "v");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
|
await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
|
||||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -127,6 +175,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null resolver is rejected (ArgumentNullException).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Null_resolver_throws()
|
||||||
|
{
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(() =>
|
||||||
|
GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A test logger that captures log entries for verification.</summary>
|
/// <summary>A test logger that captures log entries for verification.</summary>
|
||||||
private sealed class CaptureLogger : ILogger
|
private sealed class CaptureLogger : ILogger
|
||||||
{
|
{
|
||||||
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A fake ISecretResolver returning one known value; null for every other name.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(
|
||||||
|
string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal)
|
||||||
|
? KnownSecretValue
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_empty_file_throws()
|
public async Task File_prefix_empty_file_throws()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
||||||
File.WriteAllText(path, " \n ");
|
File.WriteAllText(path, " \n ");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||||
ex.Message.ShouldContain("empty");
|
ex.Message.ShouldContain("empty");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
|
|||||||
public void Register_AddsFactoryToRegistry()
|
public void Register_AddsFactoryToRegistry()
|
||||||
{
|
{
|
||||||
var registry = new DriverFactoryRegistry();
|
var registry = new DriverFactoryRegistry();
|
||||||
GalaxyDriverFactoryExtensions.Register(registry);
|
GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
|
||||||
|
|
||||||
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ public sealed class GalaxyDriverReadTests
|
|||||||
// _dataReader and _subscriber are both null. The follow-up read path can't
|
// _dataReader and _subscriber are both null. The follow-up read path can't
|
||||||
// synthesise a Read without one, so it surfaces a NotSupportedException
|
// synthesise a Read without one, so it surfaces a NotSupportedException
|
||||||
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
||||||
var driver = new GalaxyDriver("g", Opts());
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.ReadAsync(["x"], CancellationToken.None));
|
driver.ReadAsync(["x"], CancellationToken.None));
|
||||||
|
|||||||
+1
-1
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubscribeAsync_NoSubscriber_Throws()
|
public async Task SubscribeAsync_NoSubscriber_Throws()
|
||||||
{
|
{
|
||||||
using var driver = new GalaxyDriver("g", Opts());
|
using var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
||||||
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_NoWriter_Throws()
|
public async Task WriteAsync_NoWriter_Throws()
|
||||||
{
|
{
|
||||||
var driver = new GalaxyDriver("g", Opts());
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
|
||||||
|
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
|
||||||
|
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||||
|
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
|
||||||
|
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
|
||||||
|
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
|
||||||
|
/// Non-secret literals and null/empty pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcUaClientSecretResolutionTests
|
||||||
|
{
|
||||||
|
private const string KnownPwName = "opcua/inst/password";
|
||||||
|
private const string KnownPwValue = "resolved-password-plaintext";
|
||||||
|
private const string KnownCertPwName = "opcua/inst/cert-pw";
|
||||||
|
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||||
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||||
|
|
||||||
|
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_password_resolves_to_store_plaintext()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" };
|
||||||
|
|
||||||
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.Password.ShouldBe(KnownPwValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_cert_password_resolves_to_store_plaintext()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" };
|
||||||
|
|
||||||
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Literal_and_null_pass_through_unchanged()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
Password = "plainpw",
|
||||||
|
UserCertificatePassword = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.Password.ShouldBe("plainpw");
|
||||||
|
resolved.UserCertificatePassword.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
|
||||||
|
/// and never leaves the <c>secret:</c> literal in place.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_secret_ref_is_fail_closed()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
|
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None));
|
||||||
|
|
||||||
|
ex.Message.ShouldContain("Password");
|
||||||
|
ex.Message.ShouldContain("opcua/inst/absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(name.Value switch
|
||||||
|
{
|
||||||
|
KnownPwName => KnownPwValue,
|
||||||
|
KnownCertPwName => KnownCertPwValue,
|
||||||
|
_ => null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Secrets-adoption (G-2c) regression guard: the AdminUI driver-config editor persists <c>DriverConfig</c>
|
||||||
|
/// as an OPAQUE JSON string through <see cref="RawTreeService"/> (see <c>UpdateDriverAsync</c> /
|
||||||
|
/// <c>CreateDriverAsync</c> — <c>entity.DriverConfig = driverConfigJson</c> verbatim, and
|
||||||
|
/// <c>LoadDriverForEditAsync</c> reads it back verbatim). A <c>secret:</c>/<c>env:</c>/<c>file:</c>
|
||||||
|
/// reference stored in a driver secret field (OpcUaClient <c>Password</c>/<c>UserCertificatePassword</c>,
|
||||||
|
/// Galaxy <c>Gateway.ApiKeySecretRef</c>) MUST survive save→reload as the literal ref string — the save
|
||||||
|
/// path must never resolve-then-resave cleartext (which would re-leak the secret into the config store and
|
||||||
|
/// defeat G-2). Secret resolution belongs ONLY at driver-instantiation / Test-connect time (Tasks 7-8),
|
||||||
|
/// never on the AdminUI persistence path — and indeed <see cref="RawTreeService"/> takes no secret resolver
|
||||||
|
/// (its only dependency is the DbContext factory), so resolution is structurally impossible here.
|
||||||
|
/// <para>
|
||||||
|
/// Reuses the shared <see cref="RawTreeTestDb"/> InMemory fixture, matching the WP3 driver-config tests.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class RawTreeServiceSecretRefRoundTripTests
|
||||||
|
{
|
||||||
|
private static (RawTreeService Service, string DbName) Seeded()
|
||||||
|
{
|
||||||
|
var name = RawTreeTestDb.SeedFresh();
|
||||||
|
return (new RawTreeService(RawTreeTestDb.Factory(name)), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] RowVersionOf(string dbName, string driverId)
|
||||||
|
{
|
||||||
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateDriver_preserves_OpcUaClient_secret_refs_verbatim_on_save_and_reload()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Seeded();
|
||||||
|
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
|
||||||
|
|
||||||
|
// The editor emits the raw ref strings the user typed — they must NOT be resolved on save.
|
||||||
|
const string config =
|
||||||
|
"{\"Password\":\"secret:opcua/x/pw\",\"UserCertificatePassword\":\"secret:opcua/x/certpw\"}";
|
||||||
|
|
||||||
|
var result = await service.UpdateDriverAsync(
|
||||||
|
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
|
||||||
|
result.Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Reload through the same seam the modal uses.
|
||||||
|
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
|
||||||
|
reloaded.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Byte-identical: nothing on the save path rewrote the string.
|
||||||
|
reloaded!.DriverConfig.ShouldBe(config);
|
||||||
|
// The literal refs survived (not resolved to cleartext, not stripped).
|
||||||
|
reloaded.DriverConfig.ShouldContain("secret:opcua/x/pw");
|
||||||
|
reloaded.DriverConfig.ShouldContain("secret:opcua/x/certpw");
|
||||||
|
|
||||||
|
// And the raw column agrees with the projection.
|
||||||
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)
|
||||||
|
.DriverConfig.ShouldBe(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateDriver_preserves_Galaxy_ApiKeySecretRef_verbatim_on_save_and_reload()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Seeded();
|
||||||
|
|
||||||
|
const string config = "{\"Gateway\":{\"ApiKeySecretRef\":\"secret:galaxy/x/apikey\"}}";
|
||||||
|
|
||||||
|
var created = await service.CreateDriverAsync(
|
||||||
|
RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "galaxy-1", "Galaxy", config);
|
||||||
|
created.Ok.ShouldBeTrue();
|
||||||
|
created.CreatedId.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var reloaded = await service.LoadDriverForEditAsync(created.CreatedId!);
|
||||||
|
reloaded.ShouldNotBeNull();
|
||||||
|
|
||||||
|
reloaded!.DriverConfig.ShouldBe(config);
|
||||||
|
reloaded.DriverConfig.ShouldContain("secret:galaxy/x/apikey");
|
||||||
|
|
||||||
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId)
|
||||||
|
.DriverConfig.ShouldBe(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateDriver_preserves_env_and_file_ref_forms_verbatim()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Seeded();
|
||||||
|
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
|
||||||
|
|
||||||
|
const string config =
|
||||||
|
"{\"Password\":\"env:OPCUA_PW\",\"UserCertificatePassword\":\"file:/run/secrets/certpw\"}";
|
||||||
|
|
||||||
|
var result = await service.UpdateDriverAsync(
|
||||||
|
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
|
||||||
|
result.Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
|
||||||
|
reloaded!.DriverConfig.ShouldBe(config);
|
||||||
|
reloaded.DriverConfig.ShouldContain("env:OPCUA_PW");
|
||||||
|
reloaded.DriverConfig.ShouldContain("file:/run/secrets/certpw");
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -4,6 +4,7 @@ using Xunit;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
|
||||||
|
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
|
||||||
|
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||||
services.AddOtOpcUaDriverFactories();
|
services.AddOtOpcUaDriverFactories();
|
||||||
|
|
||||||
using var sp = services.BuildServiceProvider();
|
using var sp = services.BuildServiceProvider();
|
||||||
@@ -38,6 +42,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
|
||||||
|
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
|
||||||
|
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||||
services.AddOtOpcUaDriverFactories();
|
services.AddOtOpcUaDriverFactories();
|
||||||
|
|
||||||
using var sp = services.BuildServiceProvider();
|
using var sp = services.BuildServiceProvider();
|
||||||
@@ -54,6 +61,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
|
||||||
|
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
|
||||||
|
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||||
services.AddOtOpcUaDriverFactories();
|
services.AddOtOpcUaDriverFactories();
|
||||||
|
|
||||||
using var sp = services.BuildServiceProvider();
|
using var sp = services.BuildServiceProvider();
|
||||||
@@ -65,4 +75,12 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
sp.GetRequiredService<DriverResiliencePipelineBuilder>()
|
sp.GetRequiredService<DriverResiliencePipelineBuilder>()
|
||||||
.ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton");
|
.ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimal ISecretResolver so AddOtOpcUaDriverFactories' registration (which resolves it for
|
||||||
|
// Galaxy/OpcUaClient secret: refs) can be built. These tests exercise no secret: refs, so
|
||||||
|
// returning null for every name is sufficient — the real resolver comes from AddZbSecrets.
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Replication;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the production DI wiring for cluster secret replication (<c>AddOtOpcUaSecrets</c>).
|
||||||
|
/// This is the registration half of a defect class that has already shipped once in this library
|
||||||
|
/// family: every unit test passed because nothing ever built a container, so a decorator that
|
||||||
|
/// could not actually be constructed went unnoticed.
|
||||||
|
///
|
||||||
|
/// Two properties matter here, and both are load-bearing on <b>driver-role</b> nodes that have no
|
||||||
|
/// auth/AdminUI surface and whose only symptom of a broken registration is drivers failing to
|
||||||
|
/// open sessions:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>
|
||||||
|
/// With replication off (the default), <see cref="ISecretStore"/> must resolve to the
|
||||||
|
/// plain <see cref="SqliteSecretStore"/> — byte-identical to the pre-replication host.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// With replication on, <see cref="ISecretStore"/> must resolve to
|
||||||
|
/// <see cref="ReplicatingSecretStore"/> <b>and</b> the undecorated concrete
|
||||||
|
/// <see cref="SqliteSecretStore"/> must still resolve, because the decorator is
|
||||||
|
/// constructed from it. A missing concrete registration is the exact gap that shipped.
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SecretsReplicationRegistrationTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a container mirroring the host's registration order: Akka first (the host calls
|
||||||
|
/// <c>AddAkka</c> before <c>AddOtOpcUaSecrets</c>), then secrets.
|
||||||
|
/// </summary>
|
||||||
|
private static ServiceProvider BuildProvider(bool replicationEnabled, ActorSystem? actorSystem = null) =>
|
||||||
|
BuildServices(replicationEnabled, actorSystem).BuildServiceProvider();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The registrations without building the provider, for assertions about which descriptor
|
||||||
|
/// won a <c>TryAdd</c> race. Resolving some of these services has side effects (constructing
|
||||||
|
/// the Akka replicator spawns an actor that needs a joined cluster), so the descriptor is
|
||||||
|
/// the only place certain defects can be observed without a real cluster.
|
||||||
|
/// </summary>
|
||||||
|
private static IServiceCollection BuildServices(bool replicationEnabled, ActorSystem? actorSystem = null)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
// A per-test SQLite path keeps these hermetic; nothing here opens the file, because
|
||||||
|
// resolving the store does not touch the database.
|
||||||
|
["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"),
|
||||||
|
["Secrets:MasterKey:Source"] = "Environment",
|
||||||
|
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
|
||||||
|
["Secrets:RunMigrationsOnStartup"] = "false",
|
||||||
|
["Secrets:Replication:Enabled"] = replicationEnabled ? "true" : "false",
|
||||||
|
["Secrets:Replication:AnnounceInterval"] = "00:00:30",
|
||||||
|
["Secrets:Replication:ActorName"] = "zb-secret-replication",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
|
||||||
|
// The replicating store reaches the ActorSystem through SecretReplicationActorProvider.
|
||||||
|
// The host supplies one via Akka.Hosting; a plain system is equivalent for registration.
|
||||||
|
if (actorSystem is not null)
|
||||||
|
services.AddSingleton(actorSystem);
|
||||||
|
|
||||||
|
services.AddOtOpcUaSecrets(configuration);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replication_disabled_resolves_the_plain_sqlite_store()
|
||||||
|
{
|
||||||
|
using var sp = BuildProvider(replicationEnabled: false);
|
||||||
|
|
||||||
|
var store = sp.GetRequiredService<ISecretStore>();
|
||||||
|
|
||||||
|
store.ShouldBeOfType<SqliteSecretStore>(
|
||||||
|
"replication is opt-in — with Secrets:Replication:Enabled false the host must resolve the "
|
||||||
|
+ "plain local store, so existing nodes are behaviourally unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replication_disabled_does_not_require_an_actor_system()
|
||||||
|
{
|
||||||
|
// A driver-role node builds its container before the ActorSystem is reachable for secret
|
||||||
|
// resolution; the default path must not have taken a dependency on it.
|
||||||
|
using var sp = BuildProvider(replicationEnabled: false);
|
||||||
|
|
||||||
|
Should.NotThrow(() => sp.GetRequiredService<ISecretStore>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replication_enabled_decorates_the_store_so_writes_publish()
|
||||||
|
{
|
||||||
|
// Asserted on the ServiceCollection rather than a built provider, deliberately. Resolving
|
||||||
|
// ISecretStore with replication on constructs ReplicatingSecretStore, which resolves
|
||||||
|
// ISecretReplicator, which eagerly spawns the replication actor, whose PreStart calls
|
||||||
|
// DistributedPubSub.Get(...) — unavailable until the node has joined a cluster. Against a
|
||||||
|
// plain ActorSystem that resolve hangs instead of failing.
|
||||||
|
//
|
||||||
|
// Akka.TestKit is the family convention for this (ScadaBridge's suites use it throughout)
|
||||||
|
// and would be the right tool, but it is NOT available here: Akka.TestKit.Xunit2 is
|
||||||
|
// xunit-v2-only, and this project is one of the 44 on xunit.v3. Adding it yields CS0433
|
||||||
|
// type conflicts. Directory.Packages.props documents the same constraint — AdminUI.Tests,
|
||||||
|
// ControlPlane.Tests and Runtime.Tests are deliberately held on xunit v2 precisely because
|
||||||
|
// no xunit.v3 TestKit ships as of Akka 1.5.62. Host wiring tests belong here, not in one of
|
||||||
|
// those three, so the descriptor is the right seam until Akka ships a v3 TestKit.
|
||||||
|
//
|
||||||
|
// Nothing is lost: the registration order IS the defect, and the library's own
|
||||||
|
// TwoNodeClusterReplicationTests already cover actor creation and convergence against a
|
||||||
|
// genuine 2-node cluster.
|
||||||
|
//
|
||||||
|
// AddSingleton (not TryAdd) appends the decorator, and the LAST registration for a service
|
||||||
|
// type is what the container resolves.
|
||||||
|
var last = BuildServices(replicationEnabled: true)
|
||||||
|
.Last(d => d.ServiceType == typeof(ISecretStore));
|
||||||
|
|
||||||
|
last.ImplementationFactory.ShouldNotBeNull(
|
||||||
|
"with replication enabled the local store must be decorated so writes publish to peers");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replication_enabled_still_registers_the_undecorated_concrete_store()
|
||||||
|
{
|
||||||
|
// The decorator is constructed from the concrete SqliteSecretStore, not from ISecretStore
|
||||||
|
// (which would recurse). Core registering it only behind ISecretStore is a gap that has
|
||||||
|
// already shipped in this library once, so pin it.
|
||||||
|
BuildServices(replicationEnabled: true)
|
||||||
|
.ShouldContain(d => d.ServiceType == typeof(SqliteSecretStore));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replication_enabled_registers_a_startup_hook_that_forces_actor_creation()
|
||||||
|
{
|
||||||
|
// The replication actor is created LAZILY on first ISecretStore resolution. A node that
|
||||||
|
// never reads or writes a secret would therefore never join anti-entropy and would silently
|
||||||
|
// never converge — so the host must register a startup hook that forces the resolution.
|
||||||
|
using var sp = BuildProvider(replicationEnabled: true);
|
||||||
|
|
||||||
|
var starter = sp.GetServices<IHostedService>().OfType<SecretReplicationStarter>().SingleOrDefault();
|
||||||
|
|
||||||
|
starter.ShouldNotBeNull(
|
||||||
|
"replication must not depend on something happening to resolve ISecretStore later");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replication_binds_a_real_replicator_not_the_no_op_sink()
|
||||||
|
{
|
||||||
|
// THE REGRESSION GUARD for the upstream defect found during this adoption.
|
||||||
|
// ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 never bound its own ISecretReplicator:
|
||||||
|
// AddZbSecretsAkkaReplication called AddZbSecrets FIRST, which does
|
||||||
|
// TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>(), so the package's own TryAdd
|
||||||
|
// was silently discarded. Writes published into a sink and no actor was ever spawned, with
|
||||||
|
// no exception and no log line. Fixed in 0.2.1 by registering before that call.
|
||||||
|
var descriptor = BuildServices(replicationEnabled: true)
|
||||||
|
.Single(d => d.ServiceType == typeof(ISecretReplicator));
|
||||||
|
|
||||||
|
// The no-op is registered by concrete type; the real replicator via a factory, because it
|
||||||
|
// needs the lazily-created actor ref. A factory here proves the package's descriptor won.
|
||||||
|
descriptor.ImplementationType.ShouldBeNull(
|
||||||
|
"ISecretReplicator resolved to the no-op sink — replication would publish into nothing");
|
||||||
|
descriptor.ImplementationFactory.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replication_disabled_registers_no_startup_hook()
|
||||||
|
{
|
||||||
|
using var sp = BuildProvider(replicationEnabled: false);
|
||||||
|
|
||||||
|
sp.GetServices<IHostedService>().OfType<SecretReplicationStarter>().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+404
@@ -0,0 +1,404 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory
|
||||||
|
/// <c>BaseEventType</c> identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. All three
|
||||||
|
/// previously arrived <b>null</b> on every condition event: <c>MaterialiseAlarmCondition</c> never assigned
|
||||||
|
/// them, and nothing downstream synthesises them (<c>ReportEvent</c> / <c>InstanceStateSnapshot</c> copy the
|
||||||
|
/// condition's children verbatim), so a conforming client could not attribute an alarm to its source.
|
||||||
|
///
|
||||||
|
/// <para>This is the WIRE-level guard the node-level
|
||||||
|
/// <c>NodeManagerAlarmSourceFieldsTests</c> cannot give: it proves the values survive the snapshot +
|
||||||
|
/// serialization all the way into a real client's <c>EventFieldList</c>. The select clause is deliberately
|
||||||
|
/// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects
|
||||||
|
/// <c>[EventType, SourceNode, SourceName, Time, Message, Severity]</c> — so this test fails the way that
|
||||||
|
/// client failed.</para>
|
||||||
|
///
|
||||||
|
/// <para>Boots the real <see cref="OtOpcUaSdkServer"/> and drives the production
|
||||||
|
/// <see cref="SdkAddressSpaceSink"/>, mirroring <c>NativeAlarmMultiNotifierEventDeliveryTests</c>. Heavy
|
||||||
|
/// in-process server+client integration — runs in the serial integration pass.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||||
|
{
|
||||||
|
private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
|
||||||
|
private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields";
|
||||||
|
private const string QualityServerUri = "urn:OtOpcUa.AlarmEventQualityField";
|
||||||
|
|
||||||
|
private const string RawDeviceFolder = "pymodbus/plc";
|
||||||
|
private const string RawAlarmPath = "pymodbus/plc/HR200";
|
||||||
|
private const string LeafName = "HR200";
|
||||||
|
private const string Equip1 = "EQ-filling-line1-station1";
|
||||||
|
private const string AlarmMessage = "HR200 over limit (identity-field test)";
|
||||||
|
|
||||||
|
// Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause.
|
||||||
|
private const int EventTypeIndex = 0;
|
||||||
|
private const int SourceNodeIndex = 1;
|
||||||
|
private const int SourceNameIndex = 2;
|
||||||
|
private const int TimeIndex = 3;
|
||||||
|
private const int MessageIndex = 4;
|
||||||
|
private const int SeverityIndex = 5;
|
||||||
|
|
||||||
|
// Field indices in BuildConditionClassEventFilter's clause (#475). A SEPARATE clause on purpose: the
|
||||||
|
// ScadaBridge one above is mirrored field-for-field from the real consumer and its indices are load-bearing,
|
||||||
|
// so appending to it would silently shift them. Message is re-selected here only as the collector's filter key.
|
||||||
|
private const int ConditionClassIdIndex = 0;
|
||||||
|
private const int ConditionClassNameIndex = 1;
|
||||||
|
private const int ConditionClassMessageIndex = 2;
|
||||||
|
|
||||||
|
// #477 — indices in BuildQualityEventFilter's clause [Quality, Message]. Again SEPARATE from the load-bearing
|
||||||
|
// ScadaBridge clause so its indices don't shift.
|
||||||
|
private const int QualityIndex = 0;
|
||||||
|
private const int QualityMessageIndex = 1;
|
||||||
|
|
||||||
|
/// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
|
||||||
|
/// Server-object subscriber using the standard BaseEventType select clause.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
WireCondition(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
// Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the
|
||||||
|
// expected SourceNode is built exactly as a real client would resolve it.
|
||||||
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
rawNs.ShouldBeGreaterThan((ushort)0);
|
||||||
|
|
||||||
|
var collector = new EventCollector(MessageIndex);
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
subscription.FastEventCallback = collector.OnEvents;
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
// The collector filters by our unique Message, so the item's ClientHandle is not needed here
|
||||||
|
// (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
|
||||||
|
AddEventItem(subscription, ObjectIds.Server, BuildEventFilter());
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
|
||||||
|
var fields = collector.Fields.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// --- the three fields this issue is about ---
|
||||||
|
// EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
|
||||||
|
fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
|
||||||
|
"EventType must carry the concrete condition type, not null");
|
||||||
|
|
||||||
|
// SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
|
||||||
|
fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
|
||||||
|
"SourceNode must identify the source condition node, not null");
|
||||||
|
|
||||||
|
// SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
|
||||||
|
// name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
|
||||||
|
fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
|
||||||
|
"SourceName must carry the unique RawPath, not null");
|
||||||
|
fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
|
||||||
|
"SourceName is deliberately the unique RawPath, not the ambiguous leaf name");
|
||||||
|
|
||||||
|
// The rest of the standard envelope still arrives intact (guards against a regression that
|
||||||
|
// populated identity at the cost of the existing fields).
|
||||||
|
fields[TimeIndex].Value.ShouldBeOfType<DateTime>();
|
||||||
|
((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
|
||||||
|
fields[SeverityIndex].Value.ShouldBe((ushort)700);
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Issue #475 — a live condition event delivers a populated ConditionClassId / ConditionClassName.
|
||||||
|
/// Both previously arrived unset (NodeId.Null / empty text) via the same mechanism as the #473 fields, so an
|
||||||
|
/// HMI bucketing alarms by condition class dropped every OtOpcUa alarm into an unclassified bin.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Condition_event_carries_populated_ConditionClass_fields_on_the_wire()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-condclass-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ConditionClassServerUri, ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
WireCondition(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
|
||||||
|
var collector = new EventCollector(ConditionClassMessageIndex);
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
subscription.FastEventCallback = collector.OnEvents;
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
AddEventItem(subscription, ObjectIds.Server, BuildConditionClassEventFilter());
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
|
||||||
|
var fields = collector.Fields.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so
|
||||||
|
// Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report.
|
||||||
|
fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType,
|
||||||
|
"ConditionClassId must carry a resolvable condition class, not null");
|
||||||
|
|
||||||
|
// ConditionClassName — the matching human-readable name, NOT empty text.
|
||||||
|
var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType<LocalizedText>();
|
||||||
|
className.Text.ShouldBe("BaseConditionClass",
|
||||||
|
"ConditionClassName must name the reported condition class, not be empty");
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Issue #477 — the over-the-wire proof that a native condition's source-data Quality tracks its
|
||||||
|
/// driver's connectivity. A comms-lost source (<c>WriteAlarmQuality(Bad)</c>) delivers a condition event
|
||||||
|
/// whose <c>Quality</c> is non-Good, and recovery (<c>WriteAlarmQuality(Good)</c>) delivers Good again — so a
|
||||||
|
/// client can distinguish "genuinely inactive" from "we have lost contact". Before the fix the field was the
|
||||||
|
/// accidentally-Good default (<c>default(StatusCode) == Good</c>) on every event regardless of the source.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Condition_event_Quality_tracks_source_connectivity_on_the_wire()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-quality-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", QualityServerUri, ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
WireCondition(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
|
||||||
|
var collector = new EventCollector(QualityMessageIndex);
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
subscription.FastEventCallback = collector.OnEvents;
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
AddEventItem(subscription, ObjectIds.Server, BuildQualityEventFilter());
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
// Raise the alarm while the source is healthy → Quality Good.
|
||||||
|
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
|
||||||
|
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
|
||||||
|
.ShouldBeTrue("a healthy source's condition reports Good quality");
|
||||||
|
|
||||||
|
// Device goes comms-lost (connectivity path) → the next condition event carries a Bad Quality, while
|
||||||
|
// the alarm stays active (annotation only — not asserted here, covered by the node-level test).
|
||||||
|
sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
await WaitUntilAsync(() => collector.Fields.Count >= 2, TimeSpan.FromSeconds(5));
|
||||||
|
StatusCode.IsBad((StatusCode)collector.Fields[^1][QualityIndex].Value)
|
||||||
|
.ShouldBeTrue("a comms-lost source's condition must report non-Good quality on the wire");
|
||||||
|
|
||||||
|
// Device recovers → Quality returns to Good.
|
||||||
|
sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
await WaitUntilAsync(() => collector.Fields.Count >= 3, TimeSpan.FromSeconds(5));
|
||||||
|
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
|
||||||
|
.ShouldBeTrue("a recovered source's condition reports Good quality again");
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
|
||||||
|
/// equipment folder wired as a notifier (the production topology).</summary>
|
||||||
|
private static void WireCondition(SdkAddressSpaceSink sink)
|
||||||
|
{
|
||||||
|
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw);
|
||||||
|
sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||||
|
sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlarmConditionSnapshot ActiveSnapshot() =>
|
||||||
|
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||||
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
|
||||||
|
|
||||||
|
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source, EventFilter filter)
|
||||||
|
{
|
||||||
|
var item = new MonitoredItem(subscription.DefaultItem)
|
||||||
|
{
|
||||||
|
StartNodeId = source,
|
||||||
|
AttributeId = Attributes.EventNotifier,
|
||||||
|
Filter = filter,
|
||||||
|
QueueSize = 100,
|
||||||
|
SamplingInterval = 0,
|
||||||
|
};
|
||||||
|
subscription.AddItem(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity].</summary>
|
||||||
|
private static EventFilter BuildEventFilter()
|
||||||
|
{
|
||||||
|
var filter = new EventFilter();
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#475's clause: [ConditionClassId, ConditionClassName, Message]. The two class fields are declared on
|
||||||
|
/// <c>ConditionType</c>, not BaseEventType, so they must be selected against that type or the server returns no
|
||||||
|
/// value for them regardless of the fix. Message rides along solely as the collector's filter key.</summary>
|
||||||
|
private static EventFilter BuildConditionClassEventFilter()
|
||||||
|
{
|
||||||
|
var filter = new EventFilter();
|
||||||
|
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId);
|
||||||
|
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#477's clause: [Quality, Message]. Quality is declared on <c>ConditionType</c>, so it must be
|
||||||
|
/// selected against that type. Message rides along as the collector's filter key (a quality-only change still
|
||||||
|
/// snapshots the unchanged Message).</summary>
|
||||||
|
private static EventFilter BuildQualityEventFilter()
|
||||||
|
{
|
||||||
|
var filter = new EventFilter();
|
||||||
|
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
|
||||||
|
/// server events / refresh brackets never count.</summary>
|
||||||
|
/// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
|
||||||
|
/// with — the two clauses here place it differently, so it cannot be a shared constant.</param>
|
||||||
|
private sealed class EventCollector(int messageIndex)
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly List<VariantCollection> _fields = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<VariantCollection> Fields
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _fields.ToList(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
foreach (var e in notification.Events)
|
||||||
|
{
|
||||||
|
if (e.EventFields.Count > messageIndex &&
|
||||||
|
e.EventFields[messageIndex].Value is LocalizedText lt &&
|
||||||
|
lt.Text == AlarmMessage)
|
||||||
|
{
|
||||||
|
_fields.Add(e.EventFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
|
||||||
|
int port, string pkiRoot, string appUri, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var options = new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = appUri,
|
||||||
|
ApplicationUri = appUri,
|
||||||
|
OpcUaPort = port,
|
||||||
|
PublicHostname = "127.0.0.1",
|
||||||
|
PkiStoreRoot = pkiRoot,
|
||||||
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
};
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
await host.StartAsync(server, ct);
|
||||||
|
return (server, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var appConfig = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{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, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||||
|
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||||
|
|
||||||
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||||
|
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||||
|
sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000,
|
||||||
|
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SafeDelete(string dir)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -163,6 +163,8 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
|||||||
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
|||||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
|||||||
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||||
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||||
|
|||||||
@@ -2268,6 +2268,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
||||||
/// <summary>Records an alarm-condition materialise call.</summary>
|
/// <summary>Records an alarm-condition materialise call.</summary>
|
||||||
@@ -2322,6 +2323,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <summary>Records a value write (no-op in this sink).</summary>
|
/// <summary>Records a value write (no-op in this sink).</summary>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>No-op alarm condition write call.</summary>
|
/// <summary>No-op alarm condition write call.</summary>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
@@ -2355,6 +2357,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -289,6 +290,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
|
|||||||
+354
@@ -0,0 +1,354 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #473 — every materialised Part 9 condition must carry the mandatory <c>BaseEventType</c>
|
||||||
|
/// identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. The SDK does NOT synthesise
|
||||||
|
/// them on this path: <see cref="NodeState.Create"/> initialises from the type's embedded definition,
|
||||||
|
/// which declares all three as mandatory children with NO default value, and the auto-filling
|
||||||
|
/// <see cref="BaseEventState.Initialize(ISystemContext, NodeState, EventSeverity, LocalizedText)"/>
|
||||||
|
/// overload (which would set SourceNode/SourceName from a source node) is only used for transient
|
||||||
|
/// events. <c>ReportEvent</c> and <c>InstanceStateSnapshot</c> copy the children verbatim and synthesise
|
||||||
|
/// nothing — so a field left null at materialise arrives null on the wire on EVERY condition event.
|
||||||
|
/// <para>
|
||||||
|
/// The contract locked in here (see <c>docs/AlarmTracking.md</c>): the condition node IS the source
|
||||||
|
/// node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so
|
||||||
|
/// <c>SourceNode</c> self-references the condition's own NodeId and <c>SourceName</c> carries the
|
||||||
|
/// same identifying id string (<c>alarmNodeId</c>: the RawPath for native, the ScriptedAlarmId for
|
||||||
|
/// scripted), matching <c>ConditionId</c>. The leaf/display name stays on <c>ConditionName</c>, so
|
||||||
|
/// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Issue #475 — the same mechanism leaves the mandatory <c>ConditionType</c> classification fields
|
||||||
|
/// <c>ConditionClassId</c> / <c>ConditionClassName</c> unset. The contract locked in here: a server that
|
||||||
|
/// does not model condition classes must still report <c>BaseConditionClassType</c> (Part 9's
|
||||||
|
/// "no class modelled" value) rather than null. See <c>docs/AlarmTracking.md</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||||
|
private const string RawAlarmPath = "Plant/Modbus/dev1/HR200";
|
||||||
|
|
||||||
|
private readonly string _pkiRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
/// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
|
||||||
|
/// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
|
||||||
|
/// remains on ConditionName so the short name is still available to clients.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_condition_carries_EventType_SourceNode_and_SourceName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
var rawNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
|
||||||
|
// where-clause) can resolve the type.
|
||||||
|
condition.EventType.ShouldNotBeNull();
|
||||||
|
condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||||
|
|
||||||
|
// SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
|
||||||
|
// for an alarm-bearing raw tag). Equal to ConditionId.
|
||||||
|
condition.SourceNode.ShouldNotBeNull();
|
||||||
|
condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
|
||||||
|
|
||||||
|
// SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
|
||||||
|
condition.SourceName.ShouldNotBeNull();
|
||||||
|
condition.SourceName.Value.ShouldBe(RawAlarmPath);
|
||||||
|
|
||||||
|
// The leaf name is NOT lost — it stays on ConditionName.
|
||||||
|
condition.ConditionName!.Value.ShouldBe("HR200");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A SCRIPTED condition (UNS realm, ConditionId = ScriptedAlarmId) follows the SAME uniform rule:
|
||||||
|
/// SourceNode = its own NodeId, SourceName = the ScriptedAlarmId. Scripted and native conditions share the
|
||||||
|
/// materialise path, so neither may regress independently.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Scripted_condition_carries_EventType_SourceNode_and_SourceName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Station 1", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("tank-overflow", "eq-1", "Tank Overflow", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("tank-overflow", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||||
|
condition.SourceNode!.Value.ShouldBe(new NodeId("tank-overflow", unsNs));
|
||||||
|
condition.SourceName!.Value.ShouldBe("tank-overflow");
|
||||||
|
// The human-readable name stays on ConditionName.
|
||||||
|
condition.ConditionName!.Value.ShouldBe("Tank Overflow");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type
|
||||||
|
/// falls back to the base <see cref="AlarmConditionState"/> and its EventType must report that base type
|
||||||
|
/// rather than a subtype the node does not have.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task EventType_reflects_the_fallback_base_type_for_an_unknown_alarm_type()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Station 2", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("alm-x", "eq-2", "Generic", "LimitAlarm", 500,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-x", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base-type fallback
|
||||||
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The identity fields survive a re-materialise that DROPS and re-creates the node (the native↔scripted
|
||||||
|
/// kind-swap path), so a redeploy can never leave a condition emitting null identity.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Identity_fields_survive_a_kind_swap_rematerialise()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Station 3", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
// Kind swap (scripted → native) drops the prior instance and re-creates it.
|
||||||
|
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-swap", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||||
|
condition.SourceNode!.Value.ShouldBe(new NodeId("alm-swap", unsNs));
|
||||||
|
condition.SourceName!.Value.ShouldBe("alm-swap");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#475 — a NATIVE condition (Raw realm) carries the mandatory ConditionType classification fields.
|
||||||
|
/// We do not model condition classes, so Part 9's "no class modelled" value — BaseConditionClassType — is the
|
||||||
|
/// conformant answer; the point is that it is a resolvable class NodeId a client can bucket on rather than a
|
||||||
|
/// null that drops the alarm into an unclassified bin.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_condition_carries_ConditionClassId_and_ConditionClassName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
condition.ConditionClassId.ShouldNotBeNull();
|
||||||
|
condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
||||||
|
|
||||||
|
condition.ConditionClassName.ShouldNotBeNull();
|
||||||
|
condition.ConditionClassName.Value.ShouldNotBeNull();
|
||||||
|
condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted
|
||||||
|
/// share the materialise path, so neither may regress independently.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Scripted_condition_carries_ConditionClassId_and_ConditionClassName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-4", parentNodeId: null, displayName: "Station 4", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("tank-dry", "eq-4", "Tank Dry", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("tank-dry", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
||||||
|
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 — a freshly materialised NATIVE condition reports BadWaitingForInitialData quality (not the
|
||||||
|
/// accidentally-Good default), matching the value-variable "no driver data yet" convention. Its quality only
|
||||||
|
/// becomes Good once its driver's connectivity confirms it (Layer 2).</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_condition_materialises_with_waiting_for_initial_data_quality()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition.Quality.ShouldNotBeNull();
|
||||||
|
condition.Quality.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||||
|
StatusCode.IsGood(condition.Quality.Value).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 — a SCRIPTED condition is script-computed and always "live" in v1, so it materialises Good.
|
||||||
|
/// (Scripted worst-of-input quality is deferred to Layer 3.)</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Scripted_condition_materialises_with_good_quality()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-q1", parentNodeId: null, displayName: "Station Q1", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("scripted-q", "eq-q1", "Scripted Q", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("scripted-q", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition.Quality.ShouldNotBeNull();
|
||||||
|
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 — WriteAlarmCondition projects the snapshot's Quality onto the live condition node, so a
|
||||||
|
/// Bad-quality snapshot (comms-lost source) makes the condition report non-Good.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAlarmCondition_projects_snapshot_quality_onto_the_condition()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
|
||||||
|
// A comms-lost snapshot: the alarm state is unchanged but Quality is Bad.
|
||||||
|
var badSnapshot = new AlarmConditionSnapshot(
|
||||||
|
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||||
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "HR200",
|
||||||
|
Quality: OpcUaQuality.Bad);
|
||||||
|
nm.WriteAlarmCondition(RawAlarmPath, badSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Recover: a Good snapshot restores Good quality.
|
||||||
|
var goodSnapshot = badSnapshot with { Quality = OpcUaQuality.Good };
|
||||||
|
nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 Layer 2 — the dedicated connectivity-quality path sets ONLY the condition's Quality
|
||||||
|
/// (comms lost → Bad, restored → Good) and never clobbers the node's severity / message / alarm state:
|
||||||
|
/// it is a pure annotation applied out-of-band from any alarm transition.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAlarmQuality_sets_only_quality_without_touching_state_or_severity()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
var severityBefore = condition.Severity!.Value;
|
||||||
|
var messageBefore = condition.Message!.Value?.Text;
|
||||||
|
|
||||||
|
// Device comes online → Good.
|
||||||
|
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Device goes comms-lost → Bad, but severity / message / active-ack state are untouched.
|
||||||
|
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||||
|
condition.Severity.Value.ShouldBe(severityBefore);
|
||||||
|
condition.Message.Value?.Text.ShouldBe(messageBefore);
|
||||||
|
condition.ActiveState!.Id!.Value.ShouldBeFalse(); // unchanged: annotation only
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 Layer 2 — WriteAlarmQuality on an unknown / unmaterialised node is a safe no-op (never
|
||||||
|
/// throws), mirroring the other write paths: a mid-rebuild race must not fault a connectivity update.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAlarmQuality_is_a_noop_for_an_unknown_condition()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
Should.NotThrow(() =>
|
||||||
|
nm.WriteAlarmQuality("no/such/node", OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
|
||||||
|
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
|
||||||
|
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public OtOpcUaNodeManager Nm { get; } = nm;
|
||||||
|
public ValueTask DisposeAsync() => host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BootedServer> BootAsync()
|
||||||
|
{
|
||||||
|
var host = new OpcUaApplicationHost(
|
||||||
|
new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.AlarmSourceFieldsTest",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}",
|
||||||
|
OpcUaPort = AllocateFreePort(),
|
||||||
|
PublicHostname = "localhost",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
},
|
||||||
|
NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
await host.StartAsync(server, Ct);
|
||||||
|
return new BootedServer(host, server.NodeManager!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_pkiRoot))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -523,7 +523,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
{
|
{
|
||||||
var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
|
var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||||
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m");
|
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m",
|
||||||
|
Quality: StatusCodes.Good);
|
||||||
|
|
||||||
// Equal ⇒ suppress (this is the inbound double-emit case in pure form).
|
// Equal ⇒ suppress (this is the inbound double-emit case in pure form).
|
||||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
|
||||||
@@ -537,6 +538,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
|
||||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
|
||||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
|
||||||
|
// #477 — a quality-only change (e.g. source going comms-lost, no state change) is a genuine delta.
|
||||||
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Quality = StatusCodes.Bad }).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
|
/// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
|
||||||
@@ -552,7 +555,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
|
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
|
||||||
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
|
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||||
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty);
|
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty,
|
||||||
|
Quality: StatusCodes.Good);
|
||||||
|
|
||||||
var withEmpty = withNull with { Message = string.Empty };
|
var withEmpty = withNull with { Message = string.Empty };
|
||||||
|
|
||||||
|
|||||||
+1
@@ -328,6 +328,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
|||||||
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
||||||
|
|
||||||
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
|
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
|
||||||
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
||||||
|
|||||||
+40
@@ -271,6 +271,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
evt.User.ShouldBe("device");
|
evt.User.ShouldBe("device");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
|
||||||
|
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
|
||||||
|
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
|
||||||
|
/// is the ONLY signal for a comms-lost native source.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Driver_disconnect_and_reconnect_annotate_native_condition_quality()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
|
|
||||||
|
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
|
// Comms lost → the raw condition is annotated Bad.
|
||||||
|
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
|
||||||
|
var bad = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||||
|
bad.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||||
|
bad.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
bad.Quality.ShouldBe(OpcUaQuality.Bad);
|
||||||
|
|
||||||
|
// Comms restored → annotated Good.
|
||||||
|
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
|
||||||
|
var good = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||||
|
good.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||||
|
good.Quality.ShouldBe(OpcUaQuality.Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's
|
||||||
|
/// conditions (the fan-out is scoped by DriverInstanceId).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Connectivity_for_another_driver_annotates_nothing()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
|
|
||||||
|
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
|
||||||
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
||||||
private void SubscribeToAlerts(TestProbe probe)
|
private void SubscribeToAlerts(TestProbe probe)
|
||||||
{
|
{
|
||||||
|
|||||||
+39
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new DiscoverableStubDriver();
|
var driver = new DiscoverableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
|
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||||
@@ -68,6 +71,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
|
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||||
|
|
||||||
@@ -92,6 +98,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new DiscoverableStubDriver();
|
var driver = new DiscoverableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
// Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
|
// Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver,
|
driver,
|
||||||
@@ -133,6 +142,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new YieldingDiscoverableStubDriver();
|
var driver = new YieldingDiscoverableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||||
|
|
||||||
@@ -167,6 +179,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
driverType: "Stub");
|
driverType: "Stub");
|
||||||
var driver = new YieldingDiscoverableStubDriver();
|
var driver = new YieldingDiscoverableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
|
||||||
|
|
||||||
@@ -187,6 +202,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new GrowingDiscoverableStubDriver();
|
var driver = new GrowingDiscoverableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
|
||||||
|
|
||||||
@@ -215,6 +233,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||||
|
|
||||||
@@ -239,6 +260,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||||
|
|
||||||
@@ -269,6 +293,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
// Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
|
// Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
|
||||||
// visibly more than the one pass Once must run per (re)connect.
|
// visibly more than the one pass Once must run per (re)connect.
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
@@ -314,6 +341,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
// Props must accept the new optional parameter — no throw and actor starts normally.
|
// Props must accept the new optional parameter — no throw and actor starts normally.
|
||||||
var driver = new DiscoverableStubDriver();
|
var driver = new DiscoverableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver,
|
driver,
|
||||||
rediscoverInterval: TimeSpan.FromMilliseconds(20),
|
rediscoverInterval: TimeSpan.FromMilliseconds(20),
|
||||||
@@ -340,6 +370,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
// (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
|
// (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||||
|
|
||||||
@@ -373,6 +406,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||||
|
|
||||||
@@ -400,6 +436,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
// a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
|
// a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
driver,
|
driver,
|
||||||
reconnectInterval: TimeSpan.FromSeconds(30),
|
reconnectInterval: TimeSpan.FromSeconds(30),
|
||||||
|
|||||||
+42
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new AlarmSourceStubDriver();
|
var driver = new AlarmSourceStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -77,6 +80,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new AlarmSourceStubDriver();
|
var driver = new AlarmSourceStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -110,6 +116,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new SubscribableOnlyStubDriver();
|
var driver = new SubscribableOnlyStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -170,6 +179,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
// Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window.
|
// Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window.
|
||||||
var driver = new AlarmSourceStubDriver();
|
var driver = new AlarmSourceStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
// Drive to Connected; confirm the alarm handler is attached.
|
// Drive to Connected; confirm the alarm handler is attached.
|
||||||
@@ -217,6 +229,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new AlarmSourceStubDriver();
|
var driver = new AlarmSourceStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -245,6 +260,33 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 Layer 2 — the actor announces connectivity to its parent (the host): Connected on
|
||||||
|
/// reaching the Connected state, then Connected=false on a <see cref="DriverInstanceActor.DisconnectObserved"/>,
|
||||||
|
/// then Connected=true again on the reconnect. This is the signal the host uses to annotate the driver's
|
||||||
|
/// native alarm conditions' Quality (comms lost → Bad, restored → Good).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ConnectivityChanged_is_announced_to_parent_on_connect_disconnect_and_reconnect()
|
||||||
|
{
|
||||||
|
var driver = new AlarmSourceStubDriver();
|
||||||
|
var parent = CreateTestProbe(); // deliberately does NOT ignore ConnectivityChanged — it's the subject.
|
||||||
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
|
|
||||||
|
// Initial connect → Connected=true.
|
||||||
|
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
|
||||||
|
.Connected.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Disconnect → Connected=false (this is what drives the conditions Bad).
|
||||||
|
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
|
||||||
|
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
|
||||||
|
.Connected.ShouldBeFalse();
|
||||||
|
|
||||||
|
// Reconnect → Connected=true again (drives them back Good).
|
||||||
|
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
|
||||||
|
.Connected.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
// --- stub drivers ----------------------------------------------------------------------------
|
// --- stub drivers ----------------------------------------------------------------------------
|
||||||
|
|
||||||
private class StubDriver : IDriver
|
private class StubDriver : IDriver
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new SubscribableStubDriver();
|
var driver = new SubscribableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -134,6 +137,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new SubscribableStubDriver();
|
var driver = new SubscribableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -162,6 +168,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new SubscribableStubDriver();
|
var driver = new SubscribableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||||
|
|
||||||
// Desired set arrives BEFORE connect — retained, not yet applied.
|
// Desired set arrives BEFORE connect — retained, not yet applied.
|
||||||
@@ -197,6 +206,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
// is used — the exact condition that throws NotSupportedException on the subsequent Sender read.
|
// is used — the exact condition that throws NotSupportedException on the subsequent Sender read.
|
||||||
var driver = new SubscribableStubDriver { UnsubscribeYields = true };
|
var driver = new SubscribableStubDriver { UnsubscribeYields = true };
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -242,6 +254,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var driver = new SubscribableStubDriver();
|
var driver = new SubscribableStubDriver();
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
@@ -271,6 +286,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"),
|
InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"),
|
||||||
};
|
};
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||||
@@ -304,6 +322,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
InitBehavior = cfg => cfg == v1 ? gate1.Task : gate2.Task,
|
InitBehavior = cfg => cfg == v1 ? gate1.Task : gate2.Task,
|
||||||
};
|
};
|
||||||
var parent = CreateTestProbe();
|
var parent = CreateTestProbe();
|
||||||
|
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||||
|
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||||
|
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
||||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,19 @@ public class NativeAlarmProjectorTests
|
|||||||
snap.Acknowledged.ShouldBeTrue();
|
snap.Acknowledged.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #477 — native transitions carry Good quality; comms-loss quality is applied out-of-band via the
|
||||||
|
// dedicated WriteAlarmQuality path (connectivity → DriverHostActor), NOT through the projector, so the
|
||||||
|
// projector stays quality-agnostic and its transition snapshots default to Good.
|
||||||
|
[Fact]
|
||||||
|
public void Transition_projection_defaults_to_good_quality()
|
||||||
|
{
|
||||||
|
var sut = new NativeAlarmProjector();
|
||||||
|
|
||||||
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||||
|
|
||||||
|
snap.Quality.ShouldBe(OpcUaQuality.Good);
|
||||||
|
}
|
||||||
|
|
||||||
private static AlarmEventArgs Evt(
|
private static AlarmEventArgs Evt(
|
||||||
AlarmTransitionKind kind,
|
AlarmTransitionKind kind,
|
||||||
AlarmSeverity sev = AlarmSeverity.High,
|
AlarmSeverity sev = AlarmSeverity.High,
|
||||||
|
|||||||
+1
@@ -199,6 +199,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
|||||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
|
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
|
||||||
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||||
|
|||||||
+2
@@ -119,6 +119,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
|||||||
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
|
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
|
||||||
{
|
{
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
@@ -133,6 +134,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
|||||||
private sealed class NoopSink : IOpcUaAddressSpaceSink
|
private sealed class NoopSink : IOpcUaAddressSpaceSink
|
||||||
{
|
{
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="ts">The timestamp of the state change.</param>
|
/// <param name="ts">The timestamp of the state change.</param>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||||
/// <summary>Records a materialise-alarm-condition call.</summary>
|
/// <summary>Records a materialise-alarm-condition call.</summary>
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
}, duration: TimeSpan.FromMilliseconds(500));
|
}, duration: TimeSpan.FromMilliseconds(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AlarmQualityUpdate_routes_to_sink_WriteAlarmQuality()
|
||||||
|
{
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||||
|
|
||||||
|
actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
||||||
|
"Plant/Modbus/dev1/temp_hi", OpcUaQuality.Bad, DateTime.UtcNow, Realm: AddressSpaceRealm.Raw));
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
sink.AlarmQualityQueue.Count.ShouldBe(1);
|
||||||
|
sink.AlarmQualityQueue.TryPeek(out var q).ShouldBeTrue();
|
||||||
|
q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
|
||||||
|
q.Quality.ShouldBe(OpcUaQuality.Bad);
|
||||||
|
q.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
}, duration: TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
|
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
|
||||||
/// only specifies the fields it cares about.</summary>
|
/// only specifies the fields it cares about.</summary>
|
||||||
private static AlarmConditionSnapshot Snapshot(
|
private static AlarmConditionSnapshot Snapshot(
|
||||||
@@ -577,6 +597,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
|
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
|
||||||
/// <summary>Gets the queue of recorded alarm condition updates.</summary>
|
/// <summary>Gets the queue of recorded alarm condition updates.</summary>
|
||||||
public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
|
public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
|
||||||
|
/// <summary>Gets the queue of recorded alarm-quality annotations (#477).</summary>
|
||||||
|
public ConcurrentQueue<(string AlarmNodeId, OpcUaQuality Quality, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new();
|
||||||
/// <summary>Count of rebuild calls.</summary>
|
/// <summary>Count of rebuild calls.</summary>
|
||||||
public int RebuildCalls;
|
public int RebuildCalls;
|
||||||
/// <summary>Gets the queue of recorded EnsureFolder calls.</summary>
|
/// <summary>Gets the queue of recorded EnsureFolder calls.</summary>
|
||||||
@@ -616,6 +638,10 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||||
AlarmQueue.Enqueue((alarmNodeId, state, ts));
|
AlarmQueue.Enqueue((alarmNodeId, state, ts));
|
||||||
|
|
||||||
|
/// <summary>Records an alarm-quality annotation (#477).</summary>
|
||||||
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||||
|
AlarmQualityQueue.Enqueue((alarmNodeId, quality, ts, realm));
|
||||||
|
|
||||||
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||||
|
|||||||
+51
@@ -664,6 +664,57 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
|||||||
evtFalse.HistorizeToAveva.ShouldBe(false);
|
evtFalse.HistorizeToAveva.ShouldBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — a Bad-quality dependency (no threshold crossing → no state transition) drives the
|
||||||
|
/// scripted condition's Quality out of band: the host publishes an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>
|
||||||
|
/// (Bad, Uns realm) and NO <c>/alerts</c> transition — quality is an annotation, not a state change.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var alerts = CreateTestProbe();
|
||||||
|
SubscribeToAlerts(alerts);
|
||||||
|
|
||||||
|
var (host, _) = Spawn(publish, mux);
|
||||||
|
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T", threshold: 90) }));
|
||||||
|
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||||
|
|
||||||
|
// Baseline is Good (unread inputs are not a quality signal — no load-time annotation). Drive the
|
||||||
|
// input Bad with a value below threshold: the predicate freezes (no transition), but the worst-input
|
||||||
|
// quality bucket moves Good→Bad → a QualityChanged annotation flows out of band.
|
||||||
|
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow, OpcUaQuality.Bad));
|
||||||
|
|
||||||
|
var q = publish.FishForMessage<OpcUaPublishActor.AlarmQualityUpdate>(m => m.Quality == OpcUaQuality.Bad, Timeout);
|
||||||
|
q.AlarmNodeId.ShouldBe("alm-1");
|
||||||
|
q.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
// A pure quality change is NOT a state transition: no /alerts row.
|
||||||
|
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — when a transition fires while an input is Uncertain, the projected full snapshot
|
||||||
|
/// carries that worst-of-input quality (not a clobbered Good), so the condition node reflects that its
|
||||||
|
/// state is derived from untrustworthy inputs.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Transition_snapshot_carries_worst_input_quality()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var alerts = CreateTestProbe();
|
||||||
|
SubscribeToAlerts(alerts);
|
||||||
|
|
||||||
|
var (host, _) = Spawn(publish, mux);
|
||||||
|
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T", threshold: 90) }));
|
||||||
|
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||||
|
|
||||||
|
// Above threshold (activates) but Uncertain quality — Uncertain is "ready", so the predicate runs.
|
||||||
|
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow, OpcUaQuality.Uncertain));
|
||||||
|
|
||||||
|
var state = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
|
||||||
|
state.AlarmNodeId.ShouldBe("alm-1");
|
||||||
|
state.State.Quality.ShouldBe(OpcUaQuality.Uncertain);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
|
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
|
||||||
/// drives <c>OneShotShelveAsync</c> — the resulting <see cref="AlarmTransitionEvent"/>(<c>"Shelved"</c>)
|
/// drives <c>OneShotShelveAsync</c> — the resulting <see cref="AlarmTransitionEvent"/>(<c>"Shelved"</c>)
|
||||||
/// on the alerts topic must carry <c>User == cmd.User</c> (the acting operator), NOT the generic
|
/// on the alerts topic must carry <c>User == cmd.User</c> (the acting operator), NOT the generic
|
||||||
|
|||||||
@@ -149,6 +149,21 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
|
|||||||
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
|
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#478 — the mux forwards the published source quality onto DependencyValueChanged so the
|
||||||
|
/// scripted-alarm host can derive a condition's worst-of-input quality.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Publish_quality_is_forwarded_on_DependencyValueChanged()
|
||||||
|
{
|
||||||
|
var mux = Sys.ActorOf(DependencyMuxActor.Props());
|
||||||
|
var sub = CreateTestProbe();
|
||||||
|
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
|
||||||
|
|
||||||
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||||
|
"driver-1", "tag-1", 10, OpcUaQuality.Bad, DateTime.UtcNow));
|
||||||
|
|
||||||
|
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().Quality.ShouldBe(OpcUaQuality.Bad);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
|
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
|
||||||
{
|
{
|
||||||
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>
|
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user