Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cae4c8f01 | |||
| 043e237dba | |||
| 8c5e2be92e | |||
| 6dda0549e2 | |||
| db751d12a5 | |||
| f6a3c31b60 | |||
| e08b6b0e69 | |||
| 50426d4790 | |||
| 7339a4af07 | |||
| 872cf7e37a | |||
| f1534920de | |||
| 9bb237b794 | |||
| 1424a21419 | |||
| ce383df39a | |||
| a0be76b5f0 | |||
| 73d8439412 | |||
| 8843418c54 | |||
| 772d3a5f34 | |||
| ec6598ceae |
@@ -132,6 +132,9 @@
|
||||
<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.AspNetCore" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
|
||||
<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.HistorianGateway.Client" Version="0.3.0" />
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
||||
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
||||
<package pattern="ZB.MOM.WW.Secrets" />
|
||||
<package pattern="ZB.MOM.WW.Secrets.*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</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
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
### 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="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="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(
|
||||
bool Active,
|
||||
bool Acknowledged,
|
||||
@@ -23,7 +29,8 @@ public sealed record AlarmConditionSnapshot(
|
||||
bool Enabled,
|
||||
AlarmShelvingKind Shelving,
|
||||
ushort Severity,
|
||||
string Message);
|
||||
string Message,
|
||||
OpcUaQuality Quality = OpcUaQuality.Good);
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
@@ -33,6 +33,17 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// the condition was materialised under).</param>
|
||||
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>
|
||||
/// 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
|
||||
@@ -165,6 +176,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
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,
|
||||
Disabled,
|
||||
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 =
|
||||
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>
|
||||
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
||||
/// <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
|
||||
// unsafe.
|
||||
_scratchByAlarmId.Clear();
|
||||
_lastQualityBucketByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
||||
// recompile this one. Skipping this is what made the earlier fix a
|
||||
// no-op in production.
|
||||
@@ -412,7 +424,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// OnEvent dispatch until after Release() so a slow subscriber or a
|
||||
// subscriber that re-enters the engine doesn't block / deadlock.
|
||||
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)
|
||||
{
|
||||
// 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));
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
@@ -544,10 +570,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
}
|
||||
|
||||
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);
|
||||
if (evt is not null) pendingEmissions.Add(evt);
|
||||
// A real transition carries the current worst quality so the projected full-snapshot
|
||||
// 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;
|
||||
}
|
||||
@@ -599,7 +634,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
||||
/// released.
|
||||
/// </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
|
||||
// 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
|
||||
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
||||
// 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>
|
||||
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
|
||||
/// 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 };
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -780,6 +896,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_alarms.Clear();
|
||||
_alarmsReferencing.Clear();
|
||||
_scratchByAlarmId.Clear();
|
||||
_lastQualityBucketByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
||||
// releases the emitted assemblies. The drain above ensures no evaluator is
|
||||
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
||||
@@ -851,7 +968,11 @@ public sealed record ScriptedAlarmEvent(
|
||||
EmissionKind Emission,
|
||||
DateTime TimestampUtc,
|
||||
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>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
|
||||
@@ -81,6 +81,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (!Matches(sub, evt)) continue;
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||
|
||||
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
};
|
||||
|
||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
|
||||
/// <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>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
if (string.IsNullOrWhiteSpace(opts.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
|
||||
// an AdminUI cancel still wins early.
|
||||
@@ -116,19 +120,28 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
/// 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
|
||||
/// 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 <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>
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
var apiKey = await GalaxySecretRef
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = apiKey,
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
/// <list type="bullet">
|
||||
/// <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>
|
||||
/// <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;
|
||||
/// no startup warning.</item>
|
||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
/// <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:
|
||||
/// <list type="number">
|
||||
/// <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
|
||||
/// 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>
|
||||
/// <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
|
||||
/// configs that pre-date this resolver. When a logger is supplied the
|
||||
/// resolver emits a startup warning so an operator who accidentally
|
||||
/// committed a cleartext key sees it.</item>
|
||||
/// </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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
public static class GalaxySecretRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
||||
/// <c>DriverConfig</c> JSON) and a <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.
|
||||
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
||||
/// is absent). When the ref falls through to the back-compat literal arm (an
|
||||
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
|
||||
/// <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>
|
||||
/// <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="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
||||
/// <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);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
|
||||
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
|
||||
// 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
|
||||
|
||||
+1
@@ -13,5 +13,6 @@
|
||||
<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. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
</ItemGroup>
|
||||
</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.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
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
|
||||
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
||||
// 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>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</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>
|
||||
public GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
ISecretResolver secretResolver,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
: this(driverInstanceId, options,
|
||||
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="alarmFeed">Optional custom alarm feed for testing.</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(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
|
||||
IGalaxySubscriber? subscriber = null,
|
||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||
IGalaxyAlarmFeed? alarmFeed = null,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
ILogger<GalaxyDriver>? logger = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||
? driverInstanceId
|
||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
|
||||
_driverInstanceId);
|
||||
}
|
||||
|
||||
StartDeployWatcher();
|
||||
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
||||
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
||||
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
|
||||
/// </summary>
|
||||
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
||||
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
||||
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
|
||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
// 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.
|
||||
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
|
||||
}
|
||||
}
|
||||
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
// 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.
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||
};
|
||||
// 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),
|
||||
ApiKey = apiKey,
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private void StartDeployWatcher()
|
||||
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Repository.WatchDeployEvents) return;
|
||||
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
||||
|
||||
// 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.
|
||||
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
|
||||
// rather than overwriting the field and leaking the first instance.
|
||||
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
||||
// 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(
|
||||
BuildClientOptions(_options.Gateway));
|
||||
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||
_deployWatcher = new DeployWatcher(source, _logger);
|
||||
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
|
||||
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
||||
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
||||
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
|
||||
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
||||
// 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
|
||||
/// internal ctor.
|
||||
/// </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
|
||||
// produce equivalent clients from the same options.
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
||||
// produce equivalent clients from the same options. The client-options build is
|
||||
// async now (secret: arm resolves through the shared ISecretResolver).
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||
return new TracedGalaxyHierarchySource(
|
||||
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.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
|
||||
{
|
||||
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="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>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ISecretResolver secretResolver,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
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="driverConfigJson">The driver configuration in JSON format.</param>
|
||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||
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="driverConfigJson">The driver configuration in JSON format.</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>
|
||||
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(driverConfigJson);
|
||||
|
||||
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
/// protections cover it.
|
||||
/// </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>
|
||||
/// 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.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
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="driverInstanceId">Stable logical ID from the config DB.</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,
|
||||
ILogger<OpcUaClientDriver>? logger = null)
|
||||
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
}
|
||||
|
||||
private readonly OpcUaClientDriverOptions _options;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
private readonly string _driverInstanceId;
|
||||
// ---- IAlarmSource state ----
|
||||
|
||||
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
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
|
||||
// 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 Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
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="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);
|
||||
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>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</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="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>
|
||||
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(driverConfigJson);
|
||||
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
?? throw new InvalidOperationException(
|
||||
$"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 />
|
||||
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;
|
||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||
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>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<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>
|
||||
<ThemeScripts />
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Scripting" Key="scripting">
|
||||
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
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.Driver.Galaxy.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
using ZB.MOM.WW.Secrets.Ui;
|
||||
|
||||
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
|
||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||
// 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>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -43,6 +50,15 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
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
|
||||
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
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
|
||||
// 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>();
|
||||
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;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
|
||||
private static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory,
|
||||
ISecretResolver secretResolver,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
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.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
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.Sqlite;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -72,6 +77,27 @@ if (roleSuffix is not null)
|
||||
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
|
||||
// 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
|
||||
@@ -323,6 +349,9 @@ if (hasAdmin)
|
||||
builder.Services.AddOtOpcUaAdminClients();
|
||||
}
|
||||
|
||||
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
|
||||
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,12 +11,21 @@
|
||||
"DisableLogin": false
|
||||
}
|
||||
},
|
||||
"Secrets": {
|
||||
"SqlitePath": "otopcua-secrets.db",
|
||||
"MasterKey": {
|
||||
"Source": "Environment",
|
||||
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
||||
},
|
||||
"RunMigrationsOnStartup": true,
|
||||
"ResolveCacheTtl": "00:00:30"
|
||||
},
|
||||
"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.",
|
||||
"Enabled": false,
|
||||
"Endpoint": "",
|
||||
"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,
|
||||
"AllowUntrustedServerCertificate": false,
|
||||
"CaCertificatePath": null,
|
||||
|
||||
@@ -497,6 +497,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
|
||||
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
|
||||
// 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
|
||||
@@ -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>
|
||||
/// 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
|
||||
@@ -650,7 +704,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
bool Enabled,
|
||||
AlarmShelvingKind Shelving,
|
||||
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
|
||||
/// (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,
|
||||
Shelving: ReadShelvingKind(condition),
|
||||
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
|
||||
/// 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).
|
||||
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
|
||||
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
|
||||
/// <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.)
|
||||
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).
|
||||
alarm.SetEnableState(SystemContext, true);
|
||||
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)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
@@ -570,6 +570,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
@@ -600,6 +601,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
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.
|
||||
/// </para>
|
||||
/// </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)
|
||||
{
|
||||
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
|
||||
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
|
||||
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
|
||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
|
||||
@@ -46,6 +46,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
public sealed record InitializeSucceeded(int Generation);
|
||||
public sealed record InitializeFailed(string Reason, int Generation);
|
||||
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 ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
||||
/// <summary>
|
||||
@@ -413,6 +419,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
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);
|
||||
Receive<DisconnectObserved>(msg =>
|
||||
{
|
||||
@@ -483,6 +494,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
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 ?? "{}"));
|
||||
// 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.
|
||||
|
||||
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when
|
||||
/// <see cref="Enabled"/> is <c>true</c>.
|
||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
|
||||
/// 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>
|
||||
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
|
||||
/// 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);
|
||||
/// <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>
|
||||
/// 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
|
||||
@@ -239,6 +248,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
||||
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)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
|
||||
@@ -278,11 +278,31 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
||||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
||||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
|
||||
// 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)
|
||||
{
|
||||
var e = msg.Event;
|
||||
@@ -294,6 +314,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
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/
|
||||
// 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).
|
||||
@@ -539,7 +574,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
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"/>
|
||||
/// 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.
|
||||
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)
|
||||
{
|
||||
sub.Tell(dep);
|
||||
|
||||
@@ -29,7 +29,11 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
{
|
||||
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.
|
||||
/// 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)
|
||||
=> 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 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) { }
|
||||
@@ -226,6 +228,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
public bool FolderRenameCalled { get; private set; }
|
||||
|
||||
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 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) { }
|
||||
|
||||
@@ -1005,6 +1005,104 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// 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)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
|
||||
@@ -107,6 +107,28 @@ public sealed class ScriptedAlarmSourceTests
|
||||
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>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||
|
||||
+12
-1
@@ -1,5 +1,6 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
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")]
|
||||
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
|
||||
/// so the factory wire-up picks the right browser implementation.</summary>
|
||||
|
||||
+94
-27
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up #2 — pins the three resolution forms supported by
|
||||
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||
/// and the literal-string fallback. A future DPAPI arm slots in here without
|
||||
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
|
||||
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
|
||||
/// driver and the AdminUI browser share one copy.)
|
||||
/// Follow-up #2 + G-2a — pins the five resolution forms supported by
|
||||
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
|
||||
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
|
||||
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
|
||||
/// <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>
|
||||
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>
|
||||
[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>
|
||||
[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";
|
||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||
try
|
||||
{
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
||||
(await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
|
||||
.ShouldBe("key-from-env");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
|
||||
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
||||
[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";
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}"));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
|
||||
ex.Message.ShouldContain(name);
|
||||
ex.Message.ShouldContain("unset");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
||||
[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");
|
||||
File.WriteAllText(path, " key-from-file \n");
|
||||
try
|
||||
{
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
||||
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
|
||||
.ShouldBe("key-from-file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
|
||||
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
||||
[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 ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||
ex.Message.ShouldContain(path);
|
||||
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 =====
|
||||
|
||||
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
||||
[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
|
||||
// in the DriverConfig JSON. The resolver must surface a warning so an
|
||||
// operator who committed one by accident sees it at startup.
|
||||
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");
|
||||
logger.Entries.ShouldContain(e =>
|
||||
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
|
||||
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
||||
[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
|
||||
// key (dev / parity rig). The resolver must accept it AND suppress the
|
||||
// warning so production logs aren't polluted on a deliberate dev choice.
|
||||
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");
|
||||
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>
|
||||
[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";
|
||||
Environment.SetEnvironmentVariable(name, "v");
|
||||
try
|
||||
{
|
||||
var logger = new CaptureLogger();
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
|
||||
await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
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>
|
||||
private sealed class CaptureLogger : ILogger
|
||||
{
|
||||
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
=> 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>
|
||||
[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");
|
||||
File.WriteAllText(path, " \n ");
|
||||
try
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||
ex.Message.ShouldContain("empty");
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
|
||||
public void Register_AddsFactoryToRegistry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
GalaxyDriverFactoryExtensions.Register(registry);
|
||||
GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
|
||||
|
||||
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
|
||||
// synthesise a Read without one, so it surfaces a NotSupportedException
|
||||
// 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>(() =>
|
||||
driver.ReadAsync(["x"], CancellationToken.None));
|
||||
|
||||
+1
-1
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
|
||||
[Fact]
|
||||
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>(() =>
|
||||
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
||||
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
|
||||
[Fact]
|
||||
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>(() =>
|
||||
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.Resilience;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
@@ -24,6 +25,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
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();
|
||||
|
||||
using var sp = services.BuildServiceProvider();
|
||||
@@ -38,6 +42,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
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();
|
||||
|
||||
using var sp = services.BuildServiceProvider();
|
||||
@@ -54,6 +61,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
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();
|
||||
|
||||
using var sp = services.BuildServiceProvider();
|
||||
@@ -65,4 +75,12 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
||||
sp.GetRequiredService<DriverResiliencePipelineBuilder>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
+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 WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
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="state">The full condition state snapshot.</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) { }
|
||||
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
|
||||
@@ -301,6 +301,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||
|
||||
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 RebuildAddressSpace() { }
|
||||
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="state">The full condition state snapshot.</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)
|
||||
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
||||
/// <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>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <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) { }
|
||||
/// <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) { }
|
||||
@@ -2355,6 +2357,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||
/// <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)
|
||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
@@ -289,6 +290,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <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) { }
|
||||
/// <inheritdoc />
|
||||
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(
|
||||
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).
|
||||
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 { MappedSeverity = 900 }).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
|
||||
@@ -552,7 +555,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
|
||||
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||
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 };
|
||||
|
||||
|
||||
+1
@@ -328,6 +328,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
||||
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
||||
|
||||
/// <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) { }
|
||||
|
||||
/// <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");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private void SubscribeToAlerts(TestProbe probe)
|
||||
{
|
||||
|
||||
+39
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver();
|
||||
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).
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
@@ -68,6 +71,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
|
||||
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, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -92,6 +98,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver();
|
||||
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.
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver,
|
||||
@@ -133,6 +142,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new YieldingDiscoverableStubDriver();
|
||||
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, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -167,6 +179,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
driverType: "Stub");
|
||||
var driver = new YieldingDiscoverableStubDriver();
|
||||
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, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
|
||||
|
||||
@@ -187,6 +202,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new GrowingDiscoverableStubDriver();
|
||||
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, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
|
||||
|
||||
@@ -215,6 +233,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
||||
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, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -239,6 +260,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
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, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -269,6 +293,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
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
|
||||
// visibly more than the one pass Once must run per (re)connect.
|
||||
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.
|
||||
var driver = new DiscoverableStubDriver();
|
||||
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,
|
||||
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).
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
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, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -373,6 +406,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
||||
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, 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.
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
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),
|
||||
|
||||
+42
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
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));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -77,6 +80,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
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));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -110,6 +116,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableOnlyStubDriver();
|
||||
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));
|
||||
|
||||
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.
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
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));
|
||||
|
||||
// Drive to Connected; confirm the alarm handler is attached.
|
||||
@@ -217,6 +229,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
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)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -245,6 +260,33 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
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 ----------------------------------------------------------------------------
|
||||
|
||||
private class StubDriver : IDriver
|
||||
|
||||
@@ -109,6 +109,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
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));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -134,6 +137,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
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));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -162,6 +168,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
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)));
|
||||
|
||||
// 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.
|
||||
var driver = new SubscribableStubDriver { UnsubscribeYields = true };
|
||||
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));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -242,6 +254,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
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)));
|
||||
|
||||
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"),
|
||||
};
|
||||
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)));
|
||||
|
||||
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,
|
||||
};
|
||||
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)));
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||
|
||||
|
||||
@@ -153,6 +153,19 @@ public class NativeAlarmProjectorTests
|
||||
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(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
|
||||
+1
@@ -199,6 +199,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
/// <param name="state">The full condition state snapshot.</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++;
|
||||
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
|
||||
+2
@@ -119,6 +119,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
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 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) { }
|
||||
@@ -133,6 +134,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
private sealed class NoopSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
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 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) { }
|
||||
|
||||
@@ -443,6 +443,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="state">The full condition state snapshot.</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)
|
||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <summary>Records a materialise-alarm-condition call.</summary>
|
||||
|
||||
@@ -76,6 +76,26 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
}, 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
|
||||
/// only specifies the fields it cares about.</summary>
|
||||
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();
|
||||
/// <summary>Gets the queue of recorded alarm condition updates.</summary>
|
||||
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>
|
||||
public int RebuildCalls;
|
||||
/// <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) =>
|
||||
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>
|
||||
/// <param name="alarmNodeId">The alarm 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);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
|
||||
@@ -149,6 +149,21 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
|
||||
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
|
||||
{
|
||||
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>
|
||||
|
||||
Reference in New Issue
Block a user