From b8208b331275f4f541055945bd6ce4ac2c1249e9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 13:11:34 -0400 Subject: [PATCH] test+docs(v3-batch4-wp5): 2-node dual-namespace harness tests + address-space docs Tests: - OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire, offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse and read; UNS variable Organizes-references its raw node; single-source fan-out parity (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId -> GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds. - Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline. Docs (dual-namespace reality): - CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph. - docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf). - docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples. - docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath. - docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- CLAUDE.md | 44 ++- docs/AlarmTracking.md | 27 ++ docs/Historian.md | 29 +- docs/ScriptEditor.md | 7 + docs/ScriptedAlarms.md | 25 +- docs/Uns.md | 35 ++ .../EquipmentNamespaceMaterializationTests.cs | 78 ++++- .../DualNamespaceAddressSpaceTests.cs | 326 ++++++++++++++++++ 8 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 3e7d1313..a424866c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i gRPC session. The gateway owns the COM apartment + STA pump server-side; the driver speaks `MxCommand` / `MxEvent` protos exclusively. -3. **OPC UA Server** — Exposes authored equipment tags as variable nodes. - Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`); - reads/writes/subscriptions are translated to that reference for MXAccess. +3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces** + (see below). Galaxy tags are bound by `TagConfig.FullName` + (`tag_name.AttributeName`); reads/writes/subscriptions are translated to that + reference for MXAccess. + +### v3 OPC UA Address Space (Batch 4): dual namespace + +The server exposes **two OPC UA namespaces** (replacing the single +`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` / +`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both +URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority): + +| Namespace URI | Realm | Subtree | NodeId `s=` scheme | +|---|---|---|---| +| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) | +| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** | + +- **Single source, fanned to both.** Every device value has exactly one source — + the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment + as a UNS-namespace variable that carries an **`Organizes` reference to its raw + node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`) + to the raw NodeId AND every referencing UNS NodeId with identical + value/quality/timestamp — the mux key stays single (RawPath). +- **Writes via either NodeId** route to the same driver ref under the same + `WriteOperate` gating (the node-manager write gate is realm-qualified and fails + closed); a failed device write reverts both NodeIds via the shared fan-out + (write-outcome self-correction). +- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and + fan via SDK notifiers to the raw device folder AND every referencing equipment + folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on + `ConditionId = RawPath`. +- **Historian dual-registration.** Both NodeIds register the **same** historian + tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works + through either NodeId. + +The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and +the single-namespace `EquipmentTags` materialization path are gone. See +`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` + +`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`. ### Key Concept: Tag Name and FullName @@ -219,6 +255,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th **v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`. +**v3 (Batch 4 = v3.0): dual-namespace address space.** The OPC UA server exposes **two namespaces** — `https://zb.com/otopcua/raw` (Raw device tree, `s=`) + `https://zb.com/otopcua/uns` (UNS tree, `s=///`) — replacing the single `.../ns` and the retired `EquipmentNodeIds` scheme. Every value has ONE source (the raw node's publish); a driver publish for a RawPath fans in `DriverHostActor` to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp, and each UNS variable `Organizes`-references its raw node. Writes route via **either** NodeId to the same driver ref under the same realm-qualified `WriteOperate` gate (failed-write revert works through both); native alarms materialize ONCE at the raw tag (`ConditionId = RawPath`) and fan via SDK `AddNotifier` to the raw device folder + every referencing equipment folder (one `ReportEvent`, one Server-object copy); both NodeIds register the SAME historian tagname (mux `HistorizedTagRef` stays single, keyed by RawPath). Identity authority: `V3NodeIds` + `AddressSpaceRealm` (carried explicitly at every sink seam — never parsed out of the id string). See the "v3 OPC UA Address Space (Batch 4)" section above, `docs/Uns.md`, and `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`. + The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`. ## Scripting / Script Editor diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 2216f2dd..9046cda7 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -24,6 +24,33 @@ condition — the dedup logic prefers the richer driver-native record because it carries the full operator + raise-time + category metadata that the value-driven path collapses. +## v3 Batch 4 — multi-notifier delivery (raw + equipment folders) + +Under the v3 dual-namespace address space, a native driver alarm is authored on a +**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the +raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group +folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the +same condition is wired as an **event notifier of every referencing equipment folder** +(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`): +`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` + +`EnsureFolderIsEventNotifier(equipFolder)`. + +- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder, + every referencing equipment folder, and up to the Server object. A subscriber at any one + root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot` + dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root. + Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break + Server-object dedup + Part 9 ack correlation). +- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls + `RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so + inverse-notifier entries never leak across redeploys. +- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of + which notifier root the operator subscribed at — an ack issued from an equipment-folder + subscription resolves to the same raw condition. +- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing + equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary + identity RawPath + condition NodeId) with the equipment list as display metadata. + ## Galaxy driver path (driver-native) Restored in PR B.2 of the epic. `GalaxyDriver` implements diff --git a/docs/Historian.md b/docs/Historian.md index 566f82c7..a960e997 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants: `Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant OPC UA client can discover historized capability from the node's attributes. +> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two** +> variable nodes: the Raw-namespace node (`ns=Raw, s=`) and, for each referencing +> equipment, a UNS-namespace node (`ns=UNS, s=///`). +> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` / +> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and +> returns the same series. The historization intent is single-sourced — the mux's +> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization / +> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it. + **Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that owns at least one alarm condition is already an event notifier; the server registers a `sourceName` (the equipment id) for each such folder and maps event history reads to the @@ -355,31 +364,39 @@ for the full alarm-historian routing. The `historyread` command reads historical data from any node. Supply start and end times in ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference. +> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds — +> the Raw-namespace node (`s=`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing +> equipment's UNS-namespace node (`s=///`). Both register +> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned +> at connect time per the namespace URI (`https://zb.com/otopcua/raw` / +> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than +> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative). + ```bash -# Raw history for a historized Galaxy tag (last 24 hours by default) +# Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default) otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=2;s=Plant/Modbus/dev1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" -# Limit to 100 values +# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=3;s=filling/line1/station1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ --max 100 # 1-hour average aggregate otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=2;s=Plant/Modbus/dev1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ --aggregate Average --interval 3600000 # Authenticated read (ReadOnly role or higher required) otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=2;s=Plant/Modbus/dev1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ -U reader -P password ``` diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index c3047afc..bcbba33d 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -212,6 +212,13 @@ The catalog is scoped per Blazor circuit; each call creates and disposes its own `DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results are bounded to 200 entries to keep the completion list responsive on large fleets. +> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal +> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity +> of the Raw-namespace node (`ns=Raw, s=`). A `{{equip}}/` token resolves +> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so +> scripts key off the single value source regardless of the UNS projection. The dual +> namespace changes only the OPC UA address-space surface, not script tag-path semantics. + ### Literal gate `TryGetTagPathLiteral` identifies the tag-path context by climbing from the diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md index c2a8ab74..30ac89ba 100644 --- a/docs/ScriptedAlarms.md +++ b/docs/ScriptedAlarms.md @@ -127,9 +127,25 @@ object alongside the usual `"FullName"`: (unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9 `AlarmConditionState` under its equipment folder **instead of** a value variable. No EF/schema change is required; the intent rides in the schemaless `TagConfig` -blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy +blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy (`DeploymentArtifact`) paths. +> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw +> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition +> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its +> parent notifier is the raw device/group folder. For **each referencing equipment**, the +> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern +> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` + +> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device +> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots — +> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup), +> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on +> rebuild / reference removal) so inverse-notifier entries never leak across redeploys. +> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the +> `/alerts` row lists the referencing equipment paths. See +> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of +> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`. + ### TagConfig alarm fields | Field | Values | Default | @@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm for failover. -The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`) -by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No -other configuration is required. +The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the +tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw +tags). No other configuration is required: any equipment that references the raw tag +automatically receives the alarm at its equipment folder via the multi-notifier fan-out above. ### Native-alarm OPC UA operator operations diff --git a/docs/Uns.md b/docs/Uns.md index 912958b5..5579002e 100644 --- a/docs/Uns.md +++ b/docs/Uns.md @@ -110,6 +110,41 @@ UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md). `GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw tag. There is no separate alias concept or `SystemPlatform`-kind namespace. +### OPC UA address-space projection (v3 Batch 4 — dual namespace) + +> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA +> namespaces** instead of the old single `https://zb.com/otopcua/ns`: +> +> | Namespace URI | Subtree | NodeId `s=` scheme | +> |---|---|---| +> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) | +> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** | + +Every device value has **exactly one source** — the raw tag's node in the Raw +namespace. A `UnsTagReference` projects that raw tag into an equipment as a +**UNS-namespace variable** whose NodeId leaf is the reference's **effective name** +(the display-name override else the raw tag's `Name`). The UNS variable does not +bind a driver of its own: it carries an **`Organizes` reference to its backing raw +node** and mirrors it. A single driver publish for a RawPath **fans out** to the +raw NodeId AND every referencing UNS NodeId with identical value / quality / +source-timestamp — the two NodeIds never drift. + +- **Reads / subscriptions** work through either NodeId and return the same data. +- **Writes** route through either NodeId to the same backing driver ref under the + **same `WriteOperate` gating** (a UNS write is neither more nor less privileged + than the raw write it fans from); a failed device write reverts both NodeIds via + the shared fan-out. +- **HistoryRead** works through either NodeId and returns the same series — both + NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)). +- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and + fan via SDK notifiers to the raw device folder AND every referencing equipment + folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md). + +Note the two distinct identity strings: the wire **NodeId** is the path +`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key** +above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision +space the guards enforce). They are related but not the same string. + ### Virtual tags A virtual tag is bound to an equipment and driven by a **script** (no driver). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs index 5e862830..3811e137 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs @@ -29,9 +29,11 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// real AddressSpaceComposer over the SAME seeded config (loaded back from the deploy DB) and pins /// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the /// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out). -/// Note: the composer is what WP1 lights up; the artifact-decode seam -/// (DeploymentArtifact.ParseComposition) is migrated by WP3, so the dual-tree assertion runs through -/// the composer directly rather than the full deploy → artifact round-trip. +/// Note: the composer is what WP1 lights up. WP3 migrated the artifact-decode seam +/// (DeploymentArtifact.ParseComposition) to carry both realms too, so +/// (added in WP5) pins +/// the dual tree through the FULL deploy → persisted-artifact → decode round-trip a driver node applies, and +/// additionally asserts the dual-namespace deploy seals across the redundant 2-node cluster. /// /// /// The OPC UA address-space browse is exercised separately against a real SDK node manager in @@ -142,6 +144,76 @@ public sealed class EquipmentNamespaceMaterializationTests composition.EquipmentTags.ShouldBeEmpty(); } + /// BATCH-4 harness round-trip + redundancy: deploying the seeded dual-namespace config through the + /// REAL deploy pipeline (AdminOperations → ConfigPublishCoordinator → BOTH DriverHostActors) seals on the + /// 2-node cluster, AND the persisted artifact round-trips BOTH realms via + /// — the raw tag as a Raw-realm + /// Variable keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the + /// Organizes/fan-out backing path). This is the harness-level dual-namespace materialization proof: the + /// second namespace neither breaks redundant sealing nor is lost across artifact serialization. (WP3 migrated + /// the artifact-decode seam to carry both realms, so — unlike the composer-direct test above — this exercises + /// the full deploy → persisted-artifact → decode path a driver node actually applies.) + [Fact] + public async Task Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster() + { + await using var harness = await TwoNodeClusterHarness.StartAsync(); + await harness.SeedDefaultClusterAsync("c1"); + await SeedUnsHierarchyAsync(harness); + + await using var scope = harness.NodeA.Services.CreateAsyncScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct); + result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}"); + var deploymentId = result.DeploymentId!.Value.Value; + + // Redundancy non-interference: the dual-namespace deploy seals after BOTH DriverHostActors apply — the + // second namespace does not break the redundant seal path (the ServiceLevel / primary-gate machinery + // is orthogonal to the address-space namespace count; see FailoverDuringDeploy / PrimaryGateFailover). + var artifact = Array.Empty(); + await WaitForAsync(async () => + { + await using var db = await CreateDbAsync(harness); + var d = await db.Deployments.AsNoTracking() + .FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct); + if (d is { Status: DeploymentStatus.Sealed, ArtifactBlob.Length: > 0 }) + { + artifact = d.ArtifactBlob; + return true; + } + return false; + }, TimeSpan.FromSeconds(20)); + + await using (var db = await CreateDbAsync(harness)) + { + var nodeStates = await db.NodeDeploymentStates.AsNoTracking() + .Where(s => s.DeploymentId == deploymentId) + .ToListAsync(Ct); + nodeStates.Count.ShouldBe(2, "both cluster nodes record a per-node deployment state"); + nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied); + } + + // Round-trip the PERSISTED artifact through the artifact-decode seam and pin the dual tree. + var composition = DeploymentArtifact.ParseComposition(artifact); + + // Raw subtree: the seeded raw tag survives as a Raw-realm Variable keyed by its RawPath. + var rawTag = composition.RawTags.ShouldHaveSingleItem(); + rawTag.TagId.ShouldBe("tag-speed"); + rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw); + rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed"); + + // UNS subtree: the UnsTagReference survives as a UNS-realm Variable carrying the backing RawPath (the + // Organizes UNS→Raw target + the fan-out source). + var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem(); + unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns); + unsVar.EquipmentId.ShouldBe(EquipmentId); + unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed"); + unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed"); + + // The retired equipment-namespace tag path stays empty (values flow through raw + UNS now). + composition.EquipmentTags.ShouldBeEmpty(); + } + private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness) { await using var db = await CreateDbAsync(harness); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs new file mode 100644 index 00000000..dbf9d759 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs @@ -0,0 +1,326 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// v3 Batch 4 (B4-WP5) — the over-the-wire proof of the dual-namespace address space. Boots the real +/// (exactly as SubscriptionSurvivalTests / the multi-notifier +/// alarm test do), materialises a raw device folder + a raw tag Variable (ns=Raw, s=<RawPath>) +/// and an equipment folder + a UNS reference Variable (ns=UNS, s=<Area/Line/Equip/Eff>) through +/// the PRODUCTION , wires the Organizes UNS→Raw edge, then drives a +/// real OPC UA client to assert: +/// +/// BOTH namespace URIs ( + +/// ) are registered + distinct (they replaced the single +/// .../ns). +/// Both subtrees browse + read: the raw Variable at its RawPath NodeId and the UNS Variable at its +/// equipment-path NodeId. +/// The UNS Variable Organizes-references its backing raw node. +/// Single-source fan-out parity: one driver publish (modelled as a +/// to each NodeId with identical value/quality/timestamp) +/// is read back IDENTICALLY on both NodeIds. +/// HistoryRead via EITHER NodeId returns GoodNoData under the shared historian tagname (the +/// NullHistorianDataSource default — the offline parity path). +/// The WriteOperate gate is symmetric across both NodeIds: an anonymous client write is +/// rejected identically on the raw and the UNS NodeId (fail-closed at the role gate — the POSITIVE +/// realm-qualified routing path is unit-covered in DriverHostActorWriteRoutingTests / +/// NodeManagerWriteRevertTests, which drive a role-carrying identity the node manager +/// directly). +/// +/// Heavy in-process server+client integration — runs in the serial integration pass, and is fully +/// offline-safe (no Docker / SQL / gateway; the historian read defaults to the Null source). +/// +public sealed class DualNamespaceAddressSpaceTests +{ + private const string ServerUri = "urn:OtOpcUa.DualNamespaceAddressSpace"; + + // Raw device tree (ns=Raw): Folder → tag Variable, both keyed by RawPath. + private const string RawDeviceFolder = "Plant/Modbus/dev1"; + private const string RawTagPath = "Plant/Modbus/dev1/Speed"; + // UNS equipment tree (ns=UNS): equipment Folder → reference Variable keyed by the equipment path. + private const string EquipFolder = "filling/line1/station1"; + private const string UnsVarPath = "filling/line1/station1/Speed"; + // Both NodeIds register the SAME historian tagname (the raw tag's RawPath). + private const string HistorianTagname = RawTagPath; + + [Fact] + public async Task Both_namespaces_registered_and_both_subtrees_browse_read_and_organize() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-browse-{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!); + MaterialiseDualTree(sink); + // One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload. + var ts = DateTime.UtcNow; + sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw); + sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + + // (1) both namespaces registered + distinct. + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + rawNs.ShouldBeGreaterThan((ushort)0, "the Raw namespace must be registered"); + unsNs.ShouldBeGreaterThan((ushort)0, "the UNS namespace must be registered"); + rawNs.ShouldNotBe(unsNs, "the two namespaces must have distinct indices"); + + var rawNodeId = new NodeId(RawTagPath, rawNs); + var unsNodeId = new NodeId(UnsVarPath, unsNs); + + // (2) both subtrees browse + read. + var rawValue = await session.ReadValueAsync(rawNodeId, ct); + var unsValue = await session.ReadValueAsync(unsNodeId, ct); + StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good"); + StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good"); + Convert.ToSingle(rawValue.Value).ShouldBe(42.5f); + Convert.ToSingle(unsValue.Value).ShouldBe(42.5f); + + // (3) the UNS Variable Organizes-references its backing raw node. + var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes); + var organizesRaw = refs.Any(r => + r.ReferenceTypeId == ReferenceTypeIds.Organizes && + r.NodeId.NamespaceIndex == rawNs && + r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal)); + organizesRaw.ShouldBeTrue( + $"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " + + $"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]"); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + [Fact] + public async Task Single_source_fans_to_both_nodeids_with_identical_value_quality_and_timestamp() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-fanout-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Fanout", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + MaterialiseDualTree(sink); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + + // The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with + // identical value/quality/timestamp — the fan-out drift invariant. + var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc); + sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw); + sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns); + + var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct); + var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct); + + Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value)); + unsValue.StatusCode.ShouldBe(rawValue.StatusCode); + unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp); + unsValue.SourceTimestamp.ShouldBe(ts); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + [Fact] + public async Task HistoryRead_via_either_nodeid_returns_GoodNoData_under_the_shared_tagname() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-hist-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".History", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + MaterialiseDualTree(sink); // both variables historized under the SAME tagname (HistorianTagname) + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + + var rawStatus = await HistoryReadRawStatusAsync(session, new NodeId(RawTagPath, rawNs), ct); + var unsStatus = await HistoryReadRawStatusAsync(session, new NodeId(UnsVarPath, unsNs), ct); + + // NullHistorianDataSource default: a historized node's HistoryRead surfaces GoodNoData (never an + // error) — identical for the raw and the UNS NodeId under the one registered historian tagname. + rawStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "raw NodeId HistoryRead"); + unsStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "uns NodeId HistoryRead"); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + [Fact] + public async Task WriteOperate_gate_is_symmetric_across_both_nodeids() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-write-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Write", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + MaterialiseDualTree(sink); // both variables writable + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + + var rawWrite = await WriteValueStatusAsync(session, new NodeId(RawTagPath, rawNs), 7.0f, ct); + var unsWrite = await WriteValueStatusAsync(session, new NodeId(UnsVarPath, unsNs), 7.0f, ct); + + // The anonymous session carries no WriteOperate role, so the node manager's fail-closed write gate + // rejects BOTH writes identically — the gate applies uniformly to the raw and the UNS NodeId (a + // UNS write is neither more nor less privileged than the raw write it fans from). + rawWrite.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "raw NodeId write (no WriteOperate role)"); + unsWrite.ShouldBe(rawWrite, "uns NodeId write rejected identically to the raw NodeId"); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + /// Materialise the raw device folder + raw tag Variable, the equipment folder + UNS reference + /// Variable (both historized under the SAME tagname, both writable), and the Organizes UNS→Raw edge. + private static void MaterialiseDualTree(SdkAddressSpaceSink sink) + { + sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); + sink.EnsureVariable(RawTagPath, RawDeviceFolder, "Speed", "Float", writable: true, + realm: AddressSpaceRealm.Raw, historianTagname: HistorianTagname); + + sink.EnsureFolder(EquipFolder, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); + sink.EnsureVariable(UnsVarPath, EquipFolder, "Speed", "Float", writable: true, + realm: AddressSpaceRealm.Uns, historianTagname: HistorianTagname); + + // Organizes UNS→Raw: the UNS variable references its backing raw node (the fan-out source). + sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes"); + } + + private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> + BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType) + { + // Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct. + var (_, cp, refs) = await session.BrowseAsync( + null, null, node, 0u, BrowseDirection.Forward, referenceType, + includeSubtypes: true, nodeClassMask: 0u); + return (cp, refs ?? new ReferenceDescriptionCollection()); + } + + private static async Task HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct) + { + var details = new ReadRawModifiedDetails + { + StartTime = DateTime.UtcNow.AddHours(-1), + EndTime = DateTime.UtcNow, + NumValuesPerNode = 10, + IsReadModified = false, + ReturnBounds = false, + }; + var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }; + var response = await session.HistoryReadAsync( + null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct); + response.Results.ShouldNotBeNull(); + response.Results.Count.ShouldBe(1); + return response.Results[0].StatusCode; + } + + private static async Task WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct) + { + var writeCollection = new WriteValueCollection + { + new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) }, + }; + var response = await session.WriteAsync(null, writeCollection, ct); + response.Results.ShouldNotBeNull(); + response.Results.Count.ShouldBe(1); + return response.Results[0]; + } + + 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.None }, + AutoAcceptUntrustedClientCertificates = true, + }; + var server = new OtOpcUaSdkServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, ct); + return (server, host); + } + + private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.DualNamespaceClient", + ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{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: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); + } + + 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 */ } + } + } +}