From 780f363f169408a8b86828778b2dd0f3a3edeecf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 11:47:41 -0500 Subject: [PATCH 1/9] docs: document OPC UA 2.0 compatibility and rollout --- docs/Historian.md | 31 ++ docs/drivers/OpcUaClient-Test-Fixture.md | 237 +++++----------- docs/drivers/OpcUaClient.md | 32 ++- docs/plans/2026-11-10-opcua-sdk-2-rollout.md | 284 +++++++++++++++++++ docs/security.md | 31 ++ 5 files changed, 445 insertions(+), 170 deletions(-) create mode 100644 docs/plans/2026-11-10-opcua-sdk-2-rollout.md diff --git a/docs/Historian.md b/docs/Historian.md index 487b8962b..18e70b426 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -22,6 +22,37 @@ Design references: [docs/plans/2026-06-14-galaxy-phase-c-historian-design.md](pl --- +## SDK 2.0.1 compatibility (#782) + +The server targets exact OPC Foundation SDK **2.0.1**, replacing **1.5.378.182**. +The upgrade preserves existing application results, including the SQL backends' +documented divergences from the old SDK below. Live `AggregateFilter` calculations +use an application-private source port of the **37 legacy aggregate factories**, +registered through the public SDK aggregate manager; they do not adopt SDK 2's new +algorithms. Runtime and advertised aggregate defaults retain +`TreatUncertainAsBad=false`. History backend capability sets remain separate from +the live aggregate set; no backend gains aggregates or history operations through +this upgrade. + +The isolated [original-SDK oracle](../scripts/validation/opcua-aggregate-baseline/README.md) +generates **6,144 vectors** using the published 1.5.378.182 package, covering value +kinds, statuses, timestamps/picoseconds, reverse reads and edge configurations. +These are expected-output fixtures, not a claim that release validation passed. +The candidate never loads the old SDK binary. Older references below to "the SDK" +as an aggregate oracle mean that preserved baseline, rather than SDK 2's algorithms. + +SDK FILETIME-zero timestamps are translated back to the application's existing CLR +unset-time sentinel at history boundaries. Opaque history continuation points remain +session-owned, node/topology-bound and subject to current authorization; reconnect or +rollback requires a fresh read. Request cancellation flows to the backend. No +ConfigDb migration is planned; actual schema and serialization effects must be +recorded at the [rollout gate](plans/2026-11-10-opcua-sdk-2-rollout.md). + +`HistorianGateway.Client` and `HistorianGateway.Contracts` are already **0.4.0** +(on gateway master `16111a5e`, historiangw#119 closed). This package alignment is +separate from the real gateway validation tracked by historiangw#78; it is not +live AVEVA or backend-conformance evidence for SDK 2. + ## Historized TagConfig schema A tag is historized by setting the **Historize this tag** checkbox and optional **Historian diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md index 6c03a15fc..5e0ffcb24 100644 --- a/docs/drivers/OpcUaClient-Test-Fixture.md +++ b/docs/drivers/OpcUaClient-Test-Fixture.md @@ -1,185 +1,88 @@ # OPC UA Client test fixture -Coverage map + gap inventory for the OPC UA Client (gateway / aggregation) -driver. +This is the fixture and command map for the driver/browser upgrade from SDK +**1.5.378.182** to exact **2.0.1** (#782). It describes available tests; execution, +counts, skips and release decisions belong in [#782](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/782) +and the linked [execution evidence](../plans/2026-11-10-opcua-sdk-2-evidence.md). +A listed test or historical run is not candidate release evidence. -**TL;DR:** Wire-level coverage now exists via -[opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc) — Microsoft -Industrial IoT's OPC UA PLC simulator running in Docker (task #215). Real -Secure Channel, real Session, real MonitoredItem exchange against an -independent server implementation. Unit tests still carry the exhaustive -capability matrix (cert auth / security policies / reconnect / failover / -attribute mapping). Gaps remaining: upstream-server-specific quirks -(historian aggregates, typed ConditionType events, SDK-publish-queue edge -behavior under load) — opc-plc uses the same OPCFoundation stack internally -so fully-independent-stack coverage needs `open62541/open62541` as a second -image (follow-up). +## Fixture layers -## What the fixture is +| Layer | Repository location | Scope and limitation | +|---|---|---| +| Driver unit | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` | Faked session contracts: lifecycle/generations, value conversion, per-item errors, policy selection, credentials, namespace remapping, subscription cleanup, discovery, alarms and host-owned reinitialization. No real secure channel. | +| Browser unit | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/` | Browse attributes/pagination and certificate-identity ownership, including failure cleanup. No external server. | +| In-process wire | `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/` | Real driver against OtOpcUa: `OpcUaClientDriverWireTests`, `OpcUaClientDriverStallWireTests`, `OpcUaClientDriverReconnectWireTests`. Real channels but both sides use this repository's SDK. | +| External simulator | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` | `OpcUaClientSmokeTests`: real read, varied-type batch read and subscription against opc-plc. Does not prove all upstream products, secured authentication, write or alarm compatibility. | +| Browser integration | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/` | Separate fixture-backed browser tests; inspect fixture availability and individual results. | -**Integration layer** (task #215): -`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` stands up -`mcr.microsoft.com/iotedge/opc-plc:2.14.10` via `Docker/docker-compose.yml` -on `opc.tcp://10.100.0.35:50000` (the shared Docker host; override via -`OPCUA_SIM_ENDPOINT`). `OpcPlcFixture` probes the port at -collection init + skips tests with a clear message when the container's -not running (matches the Modbus/pymodbus + S7/python-snap7 skip pattern). -Docker is the launcher — no PowerShell wrapper needed because opc-plc -ships pre-containerized. Compose-file flags: `--ut` (unsecured transport -advertised), `--aa` (auto-accept client certs — opc-plc's cert trust store -resets on each spin-up), `--alm` (alarm simulation for IAlarmSource -follow-up coverage), `--pn=50000` (port). +The SDK 2 unit boundary cases include `OpcUaClientSdkValueBoundaryTests` and +`OpcUaClientContinuationPointReleaseTests`. They preserve CLR value types and +null/empty buffers, reject unsupported matrix items without shifting batch results, +and release abandoned browse points. Certificate tests cover lifetime through +session close; unit mocks alone do not establish trust-chain interoperability. -**Unit layer**: -`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is still the primary -coverage. Tests inject fakes through the driver's construction path; the -OPCFoundation.NetStandard `Session` surface is wrapped behind an interface -the tests mock. +The in-process stall proxy holds bytes without closing the session, then resumes; +its hard-close case refuses connections. These exercise stale notification rejection, +quality transitions and explicit host reinitialization. The driver does not use +`SessionReconnectHandler` or `TransferSubscriptions`; historical transfer-based +coverage descriptions do not apply to the current lifecycle (#670/#676). -## What it actually covers +The driver has **no upstream HistoryRead capability** (#655). Its tags can be +recorded into the local server-wide historian; backend conformance and aggregate +oracles are documented in [Historian.md](../Historian.md). -### Integration (opc-plc Docker, task #215) +## Running locally -- `OpcUaClientSmokeTests.Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack` — - full Secure Channel + Session + `ns=3;s=StepUp` Read round-trip -- `OpcUaClientSmokeTests.Client_reads_batch_of_varied_types_from_live_simulator` — - batch Read of UInt32 / Int32 / Boolean; asserts `bool`-specific Variant - decoding to catch a common attribute-mapping regression -- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` — - real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every - 100 ms); asserts `OnDataChange` fires within 3 s of subscribe +Run from the repository root. Build first and use a fresh result directory for +**each** project. The [runbook](../plans/2026-11-10-opcua-sdk-2-rollout.md#verification-commands) +provides the result helper that preserves test exit status and rejects all-skipped +runs. For example, after defining that helper: -Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` + -`IHostConnectivityProbe` (via the Secure Channel exchange). +```bash +opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj +opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj +opc_test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj +``` -### In-process wire tests (no opc-plc) +The simulator compose file is +`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml`. +It launches `mcr.microsoft.com/iotedge/opc-plc:2.14.10` with `--ut --aa --alm --pn=50000`. +Use the shared-host procedure in [infra/README.md](../../infra/README.md); coordinate +shared fixture use before restarting containers. These dev flags do not establish +production trust or secure-policy coverage. -`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests` drives the real driver -against the in-process OtOpcUa server (`ServerEventRig`, authenticating with the rig's API-key credential): -read (+ missing node), write through the node-write gateway (+ read-only refusal, + failed-write revert), -subscribe/unsubscribe, host-status Running -> not-Running when the server stops, and discovery. These need -no Docker. There is no HistoryRead leg: the driver has no history capability (#655). +`OpcPlcFixture` probes `OPCUA_SIM_ENDPOINT` (default `opc.tcp://10.100.0.35:50000`) +and skips when unavailable. With an available, reserved fixture: -### Unit +```bash +export OPCUA_SIM_ENDPOINT=opc.tcp://10.100.0.35:50000 +opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj +``` -The surface is broad because `OpcUaClientDriver` is the richest-capability -driver in the fleet (it's a gateway for another OPC UA server, so it -mirrors the full capability matrix): +A nonzero suite count does not prove every required scenario ran: inspect individual +TRX results and record required skips separately. No tests were run by preparing +this documentation. -- `OpcUaClientDriverScaffoldTests` — `IDriver` lifecycle -- `OpcUaClientReadWriteTests` — read + write lifecycle -- `OpcUaClientSubscribeAndProbeTests` — monitored-item subscription + probe - state transitions -- `OpcUaClientDiscoveryTests` — `GetEndpoints` + endpoint selection -- `OpcUaClientAttributeMappingTests` — OPC UA node attribute → driver value - mapping -- `OpcUaClientSecurityPolicyTests` — `SignAndEncrypt` / `Sign` / `None` - policy negotiation contract -- `OpcUaClientCertAuthTests` — cert store paths, revocation-list config -- `OpcUaClientReconnectTests` — SDK reconnect hook + `TransferSubscriptions` - across the disconnect boundary -- `OpcUaClientFailoverTests` — primary → secondary session fallback per - driver config -- `OpcUaClientAlarmTests` — A&E severity bucket (1–1000 → Low / Medium / - High / Critical), subscribe / unsubscribe / ack contract +## Required compatibility evidence -Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`, -`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`, -`IAlarmSource`. +Release validation must identify both application builds and SDK versions, endpoint +policy, token kind, certificate chain, configuration and executed capabilities. +Use the baseline client against the candidate server and candidate client against +the baseline server, plus the actual ScadaBridge adapter and external upstreams. +For secured paths include trusted SignAndEncrypt, API-key allow/deny/revocation, +certificate rejection and restart/rebind. Apply only operations the peer implements; +a simulator need not implement OtOpcUa's API-key or historian contract. -## What it does NOT cover +For alarms verify typed EventFilter select clauses, result-field indexes, actual +ConditionType/SourceNode/EventId identity, refresh, acknowledgements and no replay +after an ambiguous transport failure. For all supported paths preserve existing +operation statuses, locale behavior and null/empty filter meaning. Constructing an +empty SDK 2 `ContentFilter` is an API adaptation, not evidence of a changed filter +contract. Record any stricter protocol response and consumer impact before acceptance. -### 1. Full real-stack exchange (unit tests only) - -The **unit** suite mocks `Session.ReadAsync`, `Session.CreateSubscription`, -`Session.AddItem`, etc. — no UA Secure Channel is opened. The **integration** -suite (`OpcUaClientSmokeTests`, task #215) does open a real Secure Channel -against opc-plc and exercises Read + Subscribe end-to-end. What remains -untested even in the integration suite: certificate validation under -non-anonymous security policies, signing/encryption, nonce handling, chunk -assembly, keep-alive cadence — all SDK-internal. - -### 2. Subscription transfer across reconnect - -Contract test: "after a simulated reconnect, `TransferSubscriptions` is -called with the right handles." Real behavior: SDK re-publishes against the -new channel and some events can be lost depending on publish-queue state. -The lossy window is not characterized. - -### 3. Large-scale subscription stress - -100+ monitored items with heterogeneous publish intervals under a single -session — the shape that breaks publish-queue-size tuning in the wild — is -not exercised. - -### 4. Real A&E events - -Alarm subscription is mocked via filtered monitored items; the actual -`EventFilter` select-clause behavior against a server that exposes typed -ConditionType events (non-base `BaseEventType`) is not verified. - -### 6. Authentication variants - -- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested - but not exchanged against a server that actually enforces each. -- LDAP-backed `UserName` (matching this repo's server-side - `LdapOpcUaUserAuthenticator`) requires a live LDAP round-trip; not tested. - -## When to trust OpcUaClient tests, when to reach for a server - -| Question | Unit tests | Integration (opc-plc) | Real upstream server | -| --- | --- | --- | --- | -| "Does severity 750 bucket as High?" | yes | - | yes | -| "Does the driver call `TransferSubscriptions` after reconnect?" | yes | - | yes | -| "Does a real OPC UA read round-trip work?" | no | yes | yes | -| "Does a real OPC UA subscribe deliver changes?" | no | yes | yes | -| "Does write round-trip work against a live server?" | no | no (not yet exercised) | yes (required) | -| "Does event-filter-based alarm subscription return ConditionType events?" | no | no | yes (required) | -| "Does history read from AVEVA Historian return correct aggregates?" | no | no | yes (required) | -| "Does the SDK's publish queue lose notifications under load?" | no | no | yes (stress) | - -## Follow-up candidates - -The easiest win here is to **wire the client driver tests against this -repo's own server**. The v2 integration test project -`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs` -(the v2 replacement for the retired v1 `OpcUaServerIntegrationTests`) already -stands up a real OPC UA server on a non-default port with a seeded -FakeDriver. An `OpcUaClientLiveLoopbackTests` that connects the client -driver to that server would give: - -- Real Secure Channel negotiation -- Real Session / Subscription / MonitoredItem exchange -- Real read/write round-trip -- Real certificate validation (the integration test already sets up PKI) - -It wouldn't cover upstream-server-specific quirks (AVEVA Historian, Kepware, -Prosys), but it would cover 80% of the SDK surface the driver sits on top -of. - -Beyond that: - -1. **Prosys OPC UA Simulation Server** — free, Windows-available, scriptable. -2. **UaExpert Server-Side Simulator** — Unified Automation's sample server; - good coverage of typed ConditionType events. -3. **Dedicated historian integration lab** — only path for - historian-specific coverage. - -## Key fixture / config files - -- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with - mocked `Session` -- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs` - — collection fixture; parses `OPCUA_SIM_ENDPOINT` (default - `opc.tcp://10.100.0.35:50000`), TCP-probes at collection init, records - `SkipReason` when unreachable -- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs` - — wire-level test suite (3 `[Fact]` methods: read, batch read, subscribe) -- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml` - — `mcr.microsoft.com/iotedge/opc-plc:2.14.10` with `--ut --aa --alm --pn=50000` -- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` — ctor + - session-factory seam tests mock through; implements `IAlarmSource`; - does NOT implement `IRediscoverable` -- `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs` — - the v2 dual-endpoint integration harness a future loopback client test could - piggyback on (v1 `OpcUaServerIntegrationTests.cs` retired with the v1 server project) +opc-plc uses the OPC Foundation stack too. Independent upstream behavior, secured +authentication variants, large publish queues, real alarm products, packet-loss +windows and performance require separate evidence. The rollout runbook links the +pending gates; no simulator run substitutes for ScadaBridge, live gateway, Windows, +Linux or mixed-version cluster validation. diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 28331f085..451c6176d 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -9,8 +9,9 @@ and for the test-harness map read [OpcUaClient-Test-Fixture.md](OpcUaClient-Test A **remote OPC UA server**. This driver runs the *opposite* direction from the usual "server exposes PLC data" flow: it acts as an OPC UA **client**, opens a `Session` against an upstream server, and re-exposes that server's address space -through the local OtOpcUa server. Browse, read, write, subscribe, alarm, and -history calls are passed through to the upstream endpoint. +through the local OtOpcUa server. Browse, read, write, subscribe and alarm +operations use the upstream endpoint. HistoryRead uses the local server-wide +historian instead; see [History](#history-not-supported-gitea-655). It is built on the OPC Foundation UA .NET Standard reference SDK and runs in-process in the OtOpcUa server's .NET 10 AnyCPU host — pure managed, no @@ -20,6 +21,31 @@ out-of-process isolation. > a remote OPC UA endpoint by hand, point the general-purpose > [Client CLI](../Client.CLI.md) at it directly. +## SDK 2.0.1 compatibility (#782) + +The driver, shared session layer and AdminUI browser target exact SDK **2.0.1** +(previous **1.5.378.182**). Their existing abstractions and host-owned reconnect +lifecycle remain in place. SDK immutable values are detached at the boundary into +existing CLR scalars and one-dimensional arrays, preserving null/empty ByteStrings, +UTC timestamps, Guid values, numeric widths, item order and per-item status. Matrix +payloads remain unsupported and fail per item; this upgrade adds no matrix or +upstream-history capability. + +Certificate-based identities retain their loaded certificate until their owning +session closes, including failure cleanup. The browser releases abandoned browse +continuation points. Endpoint policy selection, namespace-URI remapping, old-session +notification rejection and the existing refusal to replay non-idempotent writes or +alarm acknowledgements remain compatibility requirements. + +No application status-code, locale-selection or event-filter semantic change is +approved by this upgrade. SDK value wrappers and empty-filter construction are API +adaptations; callers must continue to inspect operation and select-clause results +by index. Confirm actual client/server combinations with the +[fixture map](OpcUaClient-Test-Fixture.md) and +[rollout gates](../plans/2026-11-10-opcua-sdk-2-rollout.md) before deployment. +Historical SDK 1.5 observations elsewhere on this page describe their original +measurements; they are not fresh SDK 2.0.1 interoperability evidence. + ## Project split | Project | Target | Role | @@ -365,7 +391,7 @@ They are not part of `DriverHealth` or the ## Testing - **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` - cover the session lifecycle, namespace remapping, alarm/history passthrough, + cover the session lifecycle, namespace remapping, alarm dispatch, and config binding against a faked SDK session. - **Integration fixture** — exercises the driver against a reference OPC UA server (opc-plc) on the shared docker host; see diff --git a/docs/plans/2026-11-10-opcua-sdk-2-rollout.md b/docs/plans/2026-11-10-opcua-sdk-2-rollout.md new file mode 100644 index 000000000..8ceaf8b0b --- /dev/null +++ b/docs/plans/2026-11-10-opcua-sdk-2-rollout.md @@ -0,0 +1,284 @@ +# OPC UA SDK 2.0.1 rollout and rollback + +This operator runbook accompanies [#782](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/782), +the [implementation plan](2026-11-10-opcua-sdk-2-upgrade.md) and +[execution evidence](2026-11-10-opcua-sdk-2-evidence.md). Gitea is the single +status system. This draft was checked against source `a21104d2`; it does not +approve an artifact or record a deployment. The coordinator must attach final +commit-specific evidence in #782 before advancing a gate. All placeholders below +are **pending evidence**, not passing results. + +The target is exact OPC Foundation SDK **2.0.1**; the known-good SDK baseline is +**1.5.378.182** (application baseline `83ed880e4471344deaf008da9bd028c7e170bdc7`). +Restore the actual archived known-good application as a unit during rollback. +Package downgrades alone cannot reverse the application/API migration. + +## Operating constraints + +**The remote-execution hold on wonder boxes vd03/vq03/vq04 remains binding.** +No WinRM, psbridge or replacement remote-exec channel may be used until the user +clears a channel. UNC/SMB file copying and permitted SQL access do not authorize +service control, PowerShell execution or deployment on those hosts. Steps requiring +a command on a held host remain pending. No deployment was performed for this draft. + +Run the service commands below only from an approved local operator session on an +authorized Windows node. Record the node, account, channel and maintenance window +in #782 first. Linux runtime/service handling requires its own approved environment +and smoke record; this runbook supplies no invented Linux service-unit name. +Do not use `scripts/install/Refresh-Services.ps1` as a blanket upgrade action: it +also touches gateway/retired sidecar paths outside this SDK rollout. + +## Preserved contract + +- Existing raw/UNS namespace URIs, identifier forms, endpoint profiles, API-key/anonymous + admission, ACL flag behavior, role and key revocation gates remain the contract. + Source uses public transport peer evidence for miss budgets, not an IP admission rule. +- Scalar leniency, checked numeric-array conversion and pass-through driver behavior + stay as before. SDK immutable values are detached at boundaries; no new matrix support. +- No application status-code, locale or event-filter semantic change is approved. + SDK status wrappers and explicit empty `ContentFilter` construction are API changes, + not evidence of changed protocol values. Record any stricter response, field-level + filter error or localization difference with a consumer-impact decision before go. +- Auditing stays opt-in. Ordinary SDK automatic audit delivery retains its type + restrictions. Only the application's rejected-write audit gets a private, temporary + receive-permission context after current source `Read` ACL and audit/encryption checks; + no shared role metadata or general audit access is widened. See [security](../security.md). +- The private source port retains the **37 legacy aggregate factories** and existing + application results rather than adopting SDK 2 algorithms. Original-SDK fixtures + contain **6,144 vectors**. `TreatUncertainAsBad=false` stays explicit; historian + backend aggregate sets and existing documented differences remain unchanged. + SDK FILETIME-zero maps to the existing CLR unset-time sentinel at history boundaries. +- No new historian backend, companion specification, PubSub, ManagedSession, AOT or + cluster rolling-upgrade capability is claimed. HistorianGateway Client/Contracts + **0.4.0** is already on gateway master `16111a5e` (historiangw#119 closed); + the real gateway gate historiangw#78 remains separate from package alignment. + +## Release record and preflight + +Fill this record in #782 with links to immutable logs/manifests; do not paste keys, +passwords, connection strings, private certificates or unredacted environment dumps. + +| Required evidence | Record to supply before release | +|---|---| +| Candidate source and graph | Pending: full commit, clean/dirty state, SDK/runtime/OS, resolved package graph showing exact 2.0.1 | +| Known-good recovery set | Pending: deployed application version, full binary/config archive SHA-256, protected PKI backup location and permission record | +| Candidate artifacts | Pending: Windows and Linux publish directories/archives, RID/self-contained choice, SHA-256 manifest, dependencies and script-worker contents | +| G2 compilation | Pending: restore/build logs and warning/error counts at the final SHA | +| G3 behavior/security | Pending: project TRX/counts, required case results, positive and negative authorization, audit privacy, CP lifecycle and 6,144-vector comparison | +| Interoperability | Pending: baseline/candidate client-server pairs, actual ScadaBridge adapter, upstream driver/browser products and mixed-version cluster gate | +| Historian | Pending: configured SqlServer/QuestDB/Gateway capability-specific results; historiangw#78 link/evidence for live gateway | +| G4 resources/performance | Pending: baseline comparison, at least three measured runs, two-hour soak, Windows/Linux start-stop-twice smoke with preserved PKI | +| Review/operations | Pending: independent security/release review, authorized channel/window, named operator, canary/rollback results and decision | + +Before staging: + +1. Inventory actual service executable/arguments, roles, endpoints, advertised host, + application URI, raw/UNS namespace identities, certificate thumbprints, active/standby + state and connected client builds. Record configured queues, request/transport quotas, + history caps and expected load. Establish performance budgets and stop thresholds. +2. Archive complete known-good binaries and node configuration, preserving access controls. + Securely back up PKI own/private keys, peer/issuer chains, CRLs and their permissions. + Verify recovery access under the service identity, private-key presence, expiry, + application URI/hostname match and CA/key-usage suitability. Retain existing certificates. +3. Inventory ConfigDb, LocalDb, historian/outbox and secret-provider state/locations. No + ConfigDb migration is planned. Record the actual schema/serialization diff, including + Akka/deployment contracts; if persistence changes appear, reopen the design gate and + define data rollback before deployment. Never overwrite live historian/outbox data + with an old binary backup or rewrite persisted sessions/subscriptions. +4. Publish into fresh directories, hash artifacts, stage separately from the live install + and validate graph contents. Keep baseline and candidate ports, PKI, config and stores + separate in the isolated rig; never run both against the same mutable fixture. +5. Confirm required positive/negative checks, reviewers and recovery artifact access. + Missing evidence, inaccessible environments or an uncleared command channel means hold. + +## Verification commands + +These commands are recipes, not execution results. Run from the repository root in +Bash with .NET 10. Record the final SHA and logs in a fresh evidence directory; inspect +exit status before continuing. Do not pipe a failing build/test into a successful logger. + +```bash +git rev-parse HEAD +git status --short +dotnet --info +dotnet restore ZB.MOM.WW.OtOpcUa.slnx +dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release --no-restore +``` + +Define the helper once after a successful build. Each invocation creates its own +fresh TRX directory. It preserves both test failure and zero-executed-test failure. +A successful guard still requires inspection of required cases and skips. + +```bash +opc_test() { + local opc_project="$1" + local opc_results + opc_results=$(mktemp -d "${TMPDIR:-/tmp}/opcua2-test.XXXXXXXX") || return + local opc_test_status=0 + dotnet test "$opc_project" -c Release --no-build --logger trx \ + --results-directory "$opc_results" || opc_test_status=$? + local opc_count_status=0 + MIN_EXECUTED=1 bash scripts/ci/assert-not-all-skipped.sh "$opc_results"/*.trx \ + || opc_count_status=$? + printf 'TRX directory: %s\n' "$opc_results" + if [ "$opc_test_status" -ne 0 ]; then return "$opc_test_status"; fi + return "$opc_count_status" +} +opc_test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj +opc_test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj +opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.csproj +opc_test tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj +opc_test tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj +``` + +Driver/browser and simulator commands are in the +[fixture map](../drivers/OpcUaClient-Test-Fixture.md#running-locally). Backend and +live historian prerequisites are in [Historian.md](../Historian.md) and +[infra/README.md](../../infra/README.md). Live tests can write sandbox data: use +only designated sandbox tags and approved credentials through the existing secret +provider/environment. A skipped live/DB fixture never satisfies its gate. +Generate original aggregate evidence only with the isolated +[oracle procedure](../../scripts/validation/opcua-aggregate-baseline/README.md), +a new output path and original 1.5.378.182 binary; never derive expectations from +the candidate's calculators. + +Also run the full CI-equivalent test leg after integration; it does not replace +fixture-specific or live gates: + +```bash +opc_solution_test() { + local opc_results + opc_results=$(mktemp -d "${TMPDIR:-/tmp}/opcua2-solution.XXXXXXXX") || return + local opc_test_status=0 + dotnet test ZB.MOM.WW.OtOpcUa.slnx -c Release --no-build \ + -m:1 -p:TestTfmsInParallel=false --settings scripts/ci/ci.runsettings \ + --filter 'Category!=E2E&Category!=LiveIntegration' \ + --logger trx --results-directory "$opc_results" || opc_test_status=$? + local opc_count_status=0 + MIN_EXECUTED=12000 bash scripts/ci/assert-not-all-skipped.sh "$opc_results"/*.trx \ + || opc_count_status=$? + printf 'TRX directory: %s\n' "$opc_results" + if [ "$opc_test_status" -ne 0 ]; then return "$opc_test_status"; fi + return "$opc_count_status" +} +opc_solution_test +``` + +Publish the Host project to a fresh operator-selected `$OPC_PUBLISH_DIR` using the +approved `$OPC_RID` and deployment's self-contained choice. The Windows install +expects `OtOpcUa.Host.exe`; [ServiceHosting.md](../ServiceHosting.md) also describes +script-worker packaging. Example self-contained Windows artifact command: + +```bash +dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj \ + -c Release -r win-x64 --self-contained true -o "$OPC_PUBLISH_DIR" +``` + +Repeat with the approved Linux RID/framework settings in its validation environment; +cross-publishing is not runtime/install evidence. Hash the complete staged artifact +and retain a manifest outside it (PowerShell 7, where `$Candidate` and `$Manifest` +are approved absolute local paths and the manifest is outside the candidate): + +```powershell +Get-ChildItem -LiteralPath $Candidate -Recurse -File | + Get-FileHash -Algorithm SHA256 | + Export-Csv -LiteralPath $Manifest -NoTypeInformation +``` + +## Sequence and stop/go decision + +Progress only after the preceding stage's recorded checks and explicit gate decision: +**isolated rig → ScadaBridge/upstream interoperability → DEV canary → QA pair → +production canary**. Attach source/artifact identity, start/end times, operator, +checks, observations and go/hold/rollback decision for each stage to #782. + +A standby-first pair restart is allowed only after the application's mixed-version +Akka serialization, deployment/config and failover gate succeeds. Verify standby +health, security, data, history and events; exercise failover before upgrading its +peer. OPC UA wire interoperability alone does not prove cluster rolling safety. +Without that gate, arrange a maintenance window and coordinated pair restart; +record expected service interruption and recovery checks. + +The following Windows commands are **local-session steps only**. Bind `$Install` +to the inspected current binary directory, `$Candidate` to the reviewed staged +artifact and `$Backup` to a new protected directory. Keep PKI/data stores outside +binary replacement; if they are inside the install tree, define exclusions and a +separate protected backup before using these commands. Save configuration outside +`$Candidate`, then apply the reviewed node configuration to the staging directory; +never overwrite it with publisher defaults or expose secrets in command output. + +```powershell +Get-CimInstance Win32_Service -Filter "Name='OtOpcUaHost'" | + Select-Object Name, State, PathName, StartName +Stop-Service -Name OtOpcUaHost -ErrorAction Stop +(Get-Service -Name OtOpcUaHost).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(60)) +robocopy $Install $Backup /E /COPYALL /DCOPY:DAT /R:1 /W:1 +if ($LASTEXITCODE -ge 8) { throw 'Known-good backup failed' } +# After checking the backup and candidate manifest, swap complete binary directories. +# Use sibling paths on the same volume; the service PathName must still resolve to $Install. +Move-Item -LiteralPath $Install -Destination $PreviousInstall -ErrorAction Stop +Move-Item -LiteralPath $Candidate -Destination $Install -ErrorAction Stop +Start-Service -Name OtOpcUaHost -ErrorAction Stop +(Get-Service -Name OtOpcUaHost).WaitForStatus('Running', [TimeSpan]::FromSeconds(60)) +``` + +`$PreviousInstall` must be a fresh reviewed sibling path. Handle any failure between +moves by restoring that complete directory before starting. Service `Running` is +only a process check. Run the substantive checks below after every start and failover: + +- Check `/healthz`, `/health/ready` and role-appropriate `/health/active` at the + recorded HTTP base URL; compare ServiceLevel and active/standby state to baseline. +- Confirm certificate thumbprints, trust/issuer chains, endpoint policies/application + URI and namespace-URI/NodeId identity. Prove trusted SignAndEncrypt works and + untrusted/revoked/inappropriate certificates and wrong keys fail without downgrade. +- Exercise raw and UNS browse/read, scalar/array writes on sandbox nodes, subscriptions, + quality and source/server timestamps. Verify current legitimate ACL allows as well + as denies, key expiry/revocation and audience-specific event/audit filtering. +- Verify alarms/refresh/ack and no missing or duplicate events; verify configured raw, + modified, processed and event history, paging/release/reauthentication and expected + unsupported operations. Verify recorder/outbox progress without duplicate replay. +- Observe reconnects, stale values, legitimate-call denials, continuation-point errors, + session/item/certificate-handle growth, shutdown, queue depths and latency. + +Use [Client.CLI.md](../Client.CLI.md) for exact connect/browse/read/subscribe/history +and alarm syntax. Supply credentials through the approved operator mechanism; do +not record command lines containing API-key passwords in release evidence. + +Stop promotion and restore the known-good application for any authorization regression, +missing/duplicate alarm or history data, changed identifiers, insecure fallback, +restart failure or sustained performance-budget miss. Compare like hardware/config/load: +at least three warmed measurements, provisional limits ≤10% median throughput loss +and ≤15% p95 latency/allocation/RSS growth, plus at least two hours of lifecycle soak. +Any deviation needs investigation and a recorded acceptance decision. Do not lower +thresholds or count absent environments as passed. + +## Rollback + +1. Freeze promotion; record the trigger and affected nodes/clients in #782. Preserve + candidate logs and manifests with secrets redacted. Stop the candidate through the + approved local channel; the remote-exec hold still applies during rollback. +2. With the service stopped, move the candidate install to a fresh quarantine path, + restore the complete `$PreviousInstall` (or verified `$Backup`) to the inspected + service path, and restore the matching node configuration and preserved PKI with + original permissions. Check hashes/thumbprints before starting. Do not delete or + roll back live historian, outbox or ConfigDb data without a reviewed data strategy. +3. Start `OtOpcUaHost` locally with the same `Start-Service`/wait commands above. + On Linux, use only the environment's approved recorded supervisor procedure. + Verify health, endpoint/namespace/certificate identity and active/standby state. +4. Reconnect clients as needed. In-memory sessions, subscriptions and continuation + points do not survive process rollback; discard old points and begin new reads. + Rebind namespaces by URI. Never replay ambiguous non-idempotent writes or alarm + acknowledgements. Confirm API-key writes/ack, subscriptions, alarm counts and history + continuity with the same positive and negative checks used at rollout. +5. Record recovery time, checks, data reconciliation and final hold/go decision. + If rolling source back, revert package and application commits together; never + mix old package binaries with SDK 2 application assemblies. Keep candidate evidence + for diagnosis and link follow-up issues from #782. + +## Results handoff + +Pending: coordinator to attach the final SHA, artifact hashes, TRX counts/skips, +interoperability/performance/runtime evidence, reviews and per-environment operator +records in #782. This document makes no claim of a passing build, test, ScadaBridge, +Windows/Linux smoke, live backend, soak or deployment. Required inaccessible steps +remain open gates unless formally separated into linked issues with accurate scope. diff --git a/docs/security.md b/docs/security.md index 9c638e6bc..41eb370d9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -36,6 +36,37 @@ Transport security and OPC UA authentication are per-node concerns configured in ## Transport Security +### SDK 2.0.1 compatibility (#782) + +The application targets exact OPC Foundation SDK **2.0.1**, replacing **1.5.378.182**. +The upgrade retains the existing host, security profiles, anonymous-admission setting, +API-key authentication and ACL flag semantics. It adds no companion-specification or +PubSub capability. Release evidence and operator gates are linked from the +[rollout runbook](plans/2026-11-10-opcua-sdk-2-rollout.md); source changes alone do not establish release approval. + +SDK role augmentation preserves the application's principal, API-key id and application +roles. A rejected API key must remain rejected rather than falling through to another +identity provider. Per-IP authentication miss accounting uses the public transport-observed +`SecureChannelContext.PeerAddress` during activation; an unknown peer retains the existing +process-wide budget fallback. This is accounting evidence, not a client-supplied address or +a new IP admission policy. + +SDK event receive checks compose with application ACL filtering. For the application's +rejected-write `AuditWriteUpdateEvent` only, a temporary validation context supplies +`ReceiveEvents` to the recipient's SDK roles after the current source variable, effective +identity, source `Read` grant and audit/encryption guard are checked. It expires before +queueing; shared role-permission metadata is not widened. Prior permission callback +errors veto delivery. SDK-generated audits retain SDK defaults. This does not enable +auditing or grant a general audit feed. + +Certificate configuration uses the SDK certificate manager, and certificate ownership +lasts through the owning host/session's lifetime. Preserve the existing application +certificate, private key, issuer/peer stores and filesystem permissions during rollout; +verify key access and CA usage under the service identity. Do not rotate certificates, +enable auto-accept, add `None`, or relax revocation checks to get the candidate running. +Continuation points remain session-owned and bound to node/topology and current authority; +restart requires fresh sessions and fresh points. + ### Overview The OtOpcUa Server supports configurable OPC UA transport security profiles that control how data is protected on the wire between OPC UA clients and the server. From a76fca09f691780e9474870f059cb2b568156c6b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 11:46:47 -0500 Subject: [PATCH 2/9] test: converge historian and runtime fixtures on SDK 2.0 --- .../GatewayBoundingValueTests.cs | 6 +-- .../GatewayCeilingPagingTests.cs | 41 ++++++++++--------- .../GatewayRawCapTests.cs | 19 +++++---- .../NativeAlarmRestatementEndToEndTests.cs | 5 +-- .../OpcUa/ServiceLevelEndToEndTests.cs | 8 ++-- 5 files changed, 42 insertions(+), 37 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs index 0b855923a..40979a0cc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs @@ -114,7 +114,7 @@ public sealed class GatewayBoundingValueTests { var bound = await HistoryBounds.StartBoundAsync(Source(Aveva()), "T", At(-30), reverse: false, 65536, Now, Ct); - bound!.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound); + bound!.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound.Code); bound.SourceTimestampUtc.ShouldBe(At(-30)); bound.Value.ShouldBeNull(); } @@ -134,7 +134,7 @@ public sealed class GatewayBoundingValueTests after.Value.ShouldBe(5d); var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(101)), At(100), 65536, Now, Ct); - missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound); + missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound.Code); missing.SourceTimestampUtc.ShouldBe(At(101)); } @@ -156,7 +156,7 @@ public sealed class GatewayBoundingValueTests var calls = fake.ReadRawCallCount; var beyond = Now.AddDays(3); var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), beyond), At(100), 65536, Now, Ct); - missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound); + missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound.Code); missing.SourceTimestampUtc.ShouldBe(beyond); fake.ReadRawCallCount.ShouldBe(calls, "a bound beyond the horizon is not found without a read"); source.GetHealthSnapshot().TotalFailures.ShouldBe(0); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs index 374147a6e..c4c6afc9b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayCeilingPagingTests.cs @@ -106,7 +106,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable try { var first = ReadRaw(nm, nodeId, At(0), At(3600), 5000, null); - first.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + first.Error.StatusCode.ShouldBe(StatusCodes.Good); first.Values.Count.ShouldBe(FirstPage); first.Cp.ShouldNotBeNull(); // The budget the client asked for (+ lookahead + allowance, refused naming 1000), the ceiling re-read that cannot @@ -114,7 +114,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable fake.RawCalls.Select(c => c.MaxValues).ShouldBe([5000 + 1 + RawAggregateReader.PreWindowAllowance, Ceiling, Ceiling]); var (rest, _, status) = DrainFrom(nm, nodeId, At(0), At(3600), 5000, first.Cp); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); first.Values.Concat(rest).ShouldBe(PerSecond(3600).Select(s => s.Item2)); Refusals(fake).ShouldBe(1); } @@ -140,7 +140,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable ? DrainFrom(nm, nodeId, At(3600), At(-1), n, null) : DrainFrom(nm, nodeId, At(0), At(3600), n, null); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); var expected = stored.Select(s => s.Item2); values.ShouldBe(reverse ? expected.Reverse() : expected); pages[0].ShouldBe(FirstPage); @@ -217,10 +217,10 @@ public sealed class GatewayCeilingPagingTests : IDisposable var (values, pages, status) = DrainFrom(nm, nodeId, At(0), At(5000), n, null, maxPages: 60); var expected = stored.Select(s => s.Item2).ToList(); - if (status.Code == StatusCodes.Good) values.ShouldBe(expected); + if (status == StatusCodes.Good) values.ShouldBe(expected); else { - status.Code.ShouldBe(StatusCodes.BadTooManyOperations); + status.ShouldBe(StatusCodes.BadTooManyOperations); values.ShouldBe(expected.Take(values.Count)); // an exact prefix size.ShouldBeGreaterThan(L - 2, "a cluster that fits the over-fetch is always served"); } @@ -254,7 +254,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable ds.MaxRawValuesPerRead.ShouldBe(0); var (rest, pages, status) = DrainFrom(nm, nodeId, At(0), At(5000), 0, first.Cp, maxPages: 20); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); first.Values.Concat(rest).ShouldBe(stored.Select(s => s.Item2)); pages.Count.ShouldBeLessThan(20); } @@ -278,7 +278,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable fake.MaxValuesCeiling = 500; // the operator lowered it fake.RawCalls.Clear(); var (rest, pages, status) = DrainFrom(nm, nodeId, At(0), At(3600), 5000, p2.Cp, maxPages: 20); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); got.AddRange(rest); got.ShouldBe(stored.Select(s => s.Item2)); pages.ShouldAllBe(p => p <= 500 - RawAggregateReader.PreWindowAllowance); @@ -302,7 +302,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable var start = At(10).AddMilliseconds(500); var end = At(3000).AddMilliseconds(500); var (values, pages, status) = DrainFrom(nm, nodeId, start, end, 5000, null, returnBounds: true); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); values.ShouldBe(Enumerable.Range(10, 2992).Select(i => (double)i)); pages.ShouldAllBe(p => p <= L); Refusals(fake).ShouldBe(0); @@ -325,7 +325,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable try { var r = ReadRaw(nm, nodeId, DateTime.MinValue, T0.AddMilliseconds(200_000), n, null); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.ShouldBe(StatusCodes.Good); r.Cp.ShouldNotBeNull(); var page = ceiling - RawAggregateReader.PreWindowAllowance - 1; r.Values.ShouldBe(Enumerable.Range(200_000 - r.Values.Count, r.Values.Count).Reverse().Select(i => (double)i)); @@ -360,7 +360,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable try { var (values, pages, status) = DrainFrom(nm, nodeId, DateTime.UnixEpoch, DateTime.UnixEpoch.AddDays(1), 0, null); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); values.ShouldBe(stored.Select(s => s.Item2)); pages.Count.ShouldBeGreaterThan(1); } @@ -377,7 +377,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable { ReadRaw(nm, nodeId, DateTime.UnixEpoch, DateTime.UnixEpoch.AddSeconds(5), 5000, null); // learns the ceiling var r = ReadRaw(nm, nodeId, DateTime.MinValue, DateTime.UnixEpoch.AddDays(30), 10, null); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.ShouldBe(StatusCodes.Good); r.Values.ShouldBe(Enumerable.Range(2991, 10).Reverse().Select(i => (double)i)); // the LATEST ten, newest first } finally { await host.DisposeAsync(); } @@ -425,7 +425,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable fake.RawCalls.Clear(); var (values, _, status) = DrainFrom(nm, nodeId, At(0), x.AddDays(1), n, null); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); values.ShouldBe(stored.Select(s => s.Item2)); Refusals(fake).ShouldBe(0); EveryRequestFits(fake); @@ -476,7 +476,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable try { var r = ReadRaw(nm, nodeId, At(0), At(10), 10, null); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); + r.Error.StatusCode.ShouldBe(StatusCodes.BadNoCommunication); } finally { await host.DisposeAsync(); } } @@ -528,7 +528,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable private static (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) ReadRaw( OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp, bool returnBounds = false) { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var details = new ReadRawModifiedDetails { ReturnBounds = returnBounds, @@ -537,16 +537,19 @@ public sealed class GatewayCeilingPagingTests : IDisposable EndTime = end, NumValuesPerNode = max, }; - var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = inboundCp } }; + var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = inboundCp is null ? default : ByteString.From(inboundCp) } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); var values = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) - foreach (var dv in ((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues) - values.Add(Convert.ToDouble(dv.Value)); - return (values, errors[0], results[0]?.ContinuationPoint); + if (results[0]?.HistoryData is { IsNull: false } historyData) + { + var data = (HistoryData)ExtensionObject.ToEncodeable(historyData)!; + foreach (var dv in data.DataValues) + values.Add(Convert.ToDouble(SdkValueBoundary.ToClr(dv.WrappedValue))); + } + return (values, errors[0], results[0]?.ContinuationPoint is { IsNull: false } cp ? cp.ToArray() : null); } public void Dispose() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawCapTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawCapTests.cs index c1c5da7f8..91ae847e2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawCapTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawCapTests.cs @@ -205,7 +205,7 @@ public sealed class GatewayRawCapTests : IDisposable ? DrainFrom(nm, nodeId, At(3600), At(-1), n, null) : DrainFrom(nm, nodeId, At(0), At(3600), n, null); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); var expected = Enumerable.Range(0, 3600).Select(i => (double)i); values.ShouldBe(reverse ? expected.Reverse() : expected); pages.Count.ShouldBeGreaterThan(1, "a year-sized window at N = 0 is paged, not one shot"); @@ -224,7 +224,7 @@ public sealed class GatewayRawCapTests : IDisposable try { var (values, pages, status) = DrainFrom(nm, nodeId, At(10).AddMilliseconds(500), At(3000).AddMilliseconds(500), 0, null, returnBounds: true); - status.Code.ShouldBe(StatusCodes.Good); + status.ShouldBe(StatusCodes.Good); values.ShouldBe(Enumerable.Range(10, 2992).Select(i => (double)i)); // start bound +10, data +11..+3000, end bound +3001 pages.ShouldAllBe(p => p <= Cap); fake.RawCalls.ShouldAllBe(c => c.MaxValues > 0 && c.MaxValues <= Cap + RawAggregateReader.PreWindowAllowance); @@ -277,21 +277,24 @@ public sealed class GatewayRawCapTests : IDisposable private static (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) ReadRaw( OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp, bool returnBounds = false) { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var details = new ReadRawModifiedDetails { ReturnBounds = returnBounds, IsReadModified = false, StartTime = start, EndTime = end, NumValuesPerNode = max, }; - var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = inboundCp } }; + var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = inboundCp is null ? default : ByteString.From(inboundCp) } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); var values = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) - foreach (var dv in ((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues) - values.Add(Convert.ToDouble(dv.Value)); - return (values, errors[0], results[0]?.ContinuationPoint); + if (results[0]?.HistoryData is { IsNull: false } historyData) + { + var data = (HistoryData)ExtensionObject.ToEncodeable(historyData)!; + foreach (var dv in data.DataValues) + values.Add(Convert.ToDouble(SdkValueBoundary.ToClr(dv.WrappedValue))); + } + return (values, errors[0], results[0]?.ContinuationPoint is { IsNull: false } cp ? cp.ToArray() : null); } public void Dispose() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs index bcd4faf89..b611ac6b6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs @@ -28,7 +28,6 @@ using LocalizedText = Opc.Ua.LocalizedText; using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto; using UaNodeId = Opc.Ua.NodeId; using Variant = Opc.Ua.Variant; -using VariantCollection = Opc.Ua.VariantCollection; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; @@ -505,9 +504,9 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase } /// An upstream A&C condition event, in the driver's select-clause order. - private static VariantCollection ConditionFields(bool active, bool acked) => + private static Variant[] ConditionFields(bool active, bool acked) => [ - new Variant(new byte[] { 1 }), // EventId + new Variant(Opc.Ua.ByteString.From(new byte[] { 1 })), // EventId new Variant(new UaNodeId("TripAlarmType", 3)), // EventType new Variant(UaNodeId.Parse(UpstreamSource)), // SourceNode new Variant(new LocalizedText("tripped")), // Message diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs index 303400b66..06def95e5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs @@ -35,7 +35,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase port => new OpcUaApplicationHost( BuildOptions("PrimaryLeader", pkiRoot, port), NullLogger.Instance), - () => new StandardServer(), Ct); + () => new StandardServer(Opc.Ua.DefaultTelemetry.Create(_ => { })), Ct); await using var host = started.Host; var server = started.Server; @@ -58,7 +58,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase CorrelationId: CorrelationId.NewId())); AwaitAssertion(() => - server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240)); + server.CurrentInstance.ServerObject.ServiceLevel.ShouldNotBeNull().Value.ShouldBe((byte)240)); } finally { @@ -77,7 +77,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase port => new OpcUaApplicationHost( BuildOptions("Secondary", pkiRoot, port), NullLogger.Instance), - () => new StandardServer(), Ct); + () => new StandardServer(Opc.Ua.DefaultTelemetry.Create(_ => { })), Ct); await using var host = started.Host; var server = started.Server; @@ -100,7 +100,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase CorrelationId: CorrelationId.NewId())); AwaitAssertion(() => - server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)100)); + server.CurrentInstance.ServerObject.ServiceLevel.ShouldNotBeNull().Value.ShouldBe((byte)100)); } finally { From da50123a2c07a7ec22e1be43572a010b075e3d05 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 11:49:37 -0500 Subject: [PATCH 3/9] test: start service-level fixtures with authenticated SDK server --- .../OpcUa/ServiceLevelEndToEndTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs index 06def95e5..f9c677a89 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs @@ -1,6 +1,5 @@ using Akka.Actor; using Microsoft.Extensions.Logging.Abstractions; -using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; @@ -14,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; /// /// Task 60 / #81 — verifies the full path from cluster redundancy state to OPC UA -/// Server.ServiceLevel visible on the wire. Boots a real , +/// Server.ServiceLevel visible on the wire. Boots a real , /// wires into a /// (the production binding pattern), spawns against the /// deferred publisher, and sends a snapshot. Asserts @@ -35,7 +34,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase port => new OpcUaApplicationHost( BuildOptions("PrimaryLeader", pkiRoot, port), NullLogger.Instance), - () => new StandardServer(Opc.Ua.DefaultTelemetry.Create(_ => { })), Ct); + () => new OtOpcUaSdkServer(), Ct); await using var host = started.Host; var server = started.Server; @@ -77,7 +76,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase port => new OpcUaApplicationHost( BuildOptions("Secondary", pkiRoot, port), NullLogger.Instance), - () => new StandardServer(Opc.Ua.DefaultTelemetry.Create(_ => { })), Ct); + () => new OtOpcUaSdkServer(), Ct); await using var host = started.Host; var server = started.Server; From 1840b5ccf6101c220523780f215d5fefdb0118dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 11:53:34 -0500 Subject: [PATCH 4/9] test: verify live history authority and secure peer reactivation --- .../Sdk2HistoryAuthorityWireTests.cs | 156 ++++++++++++++++++ .../Sdk2TrustedChannelTests.cs | 19 +++ 2 files changed, 175 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs new file mode 100644 index 000000000..d50080820 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HistoryAuthorityWireTests.cs @@ -0,0 +1,156 @@ +using Opc.Ua; +using Opc.Ua.Client; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; +using CoreReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// Uses activated sessions and the production continuation store; denied resumes never reach a backend. +public sealed class Sdk2HistoryAuthorityWireTests +{ + private const string ValuePath = RawDeviceFolder + "/History"; + private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + + public static TheoryData Cases => new( + from kind in new[] { "raw", "modified", "events" } + from change in new[] { "alias", "replace", "rebuild", "grant", "key", "expiry" } + select (kind, change)); + + [Theory] + [MemberData(nameof(Cases))] + public async Task A_saved_cursor_requires_its_live_node_and_current_authority(string kind, string change) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("HistoryAuthority", encrypted: true, ct); + var source = new CountingSource(); + var clock = new Clock(); + var key = Key(); + key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10); + var grant = ClusterGrant(KeyPrincipal, "history-allow", NodePermissions.ReadOnly); + var state = rig.Enforce(true, [key], grant); + rig.Nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, + NullLogger.Instance, clock); + rig.Nm.HistorianDataSource = source; + Materialise(rig); + using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true); + var first = await PageAsync(session, kind, alias: false, default, ct); + first.StatusCode.ShouldBe(StatusCodes.Good); + first.ContinuationPoint.IsEmpty.ShouldBeFalse("the production session store must own a real next page"); + source.Calls.ShouldBeGreaterThan(0); + + switch (change) + { + case "replace": + if (kind == "events") rig.Nm.RemoveEquipmentSubtree(RawDeviceFolder, AddressSpaceRealm.Raw).ShouldBeTrue(); + else rig.Nm.RemoveVariableNode(ValuePath, AddressSpaceRealm.Raw).ShouldBeTrue(); + Materialise(rig); + break; + case "rebuild": + rig.Nm.RebuildAddressSpace(); Materialise(rig); rig.Nm.CompleteAddressSpaceApply(); + break; + case "grant": Apply(state, key, []); break; + case "key": + var disabled = Key(); disabled.Enabled = false; Apply(state, disabled, [grant]); break; + case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break; + } + var before = source.Calls; + var denied = await PageAsync(session, kind, change == "alias", first.ContinuationPoint, ct); + denied.StatusCode.ShouldBe(change is "grant" or "key" or "expiry" + ? StatusCodes.BadUserAccessDenied : StatusCodes.BadContinuationPointInvalid); + denied.HistoryData.IsNull.ShouldBeTrue("a denied resume must not disclose saved data"); + denied.ContinuationPoint.IsEmpty.ShouldBeTrue(); + source.Calls.ShouldBe(before, "permission and exact-node binding must be checked before backend dispatch"); + + // A fresh authorized request remains usable: this is not an always-deny fixture. + clock.Reset(); Apply(state, key, [grant]); + (await PageAsync(session, kind, alias: false, default, ct)).StatusCode.ShouldBe(StatusCodes.Good); + source.Calls.ShouldBeGreaterThan(before); + await session.CloseAsync(ct); + } + + private static void Apply(DataPlaneAuthorityState state, ApiKey key, NodeAcl[] grants) => + state.Apply(ServerEventRig.Cluster, Guid.NewGuid().ToString("N"), [key], grants, + new Dictionary>()); + + private static void Materialise(ServerEventRig rig) + { + rig.MaterialiseNative(); + rig.Sink.EnsureVariable(ValuePath, RawDeviceFolder, "History", "Double", false, AddressSpaceRealm.Raw, "same-backend-tag"); + rig.Sink.EnsureVariable(ValuePath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag"); + } + + private static async Task PageAsync(ISession session, string kind, bool alias, + ByteString point, CancellationToken ct) + { + var realm = alias ? V3NodeIds.UnsNamespaceUri : V3NodeIds.RawNamespaceUri; + var path = kind == "events" ? alias ? EqA : RawDeviceFolder : ValuePath; + var node = new NodeId(path, (ushort)session.NamespaceUris.GetIndex(realm)); + HistoryReadDetails details = kind == "events" ? new ReadEventDetails + { + StartTime = Epoch, EndTime = Epoch.AddMinutes(1), NumValuesPerNode = 2, + Filter = new EventFilter { SelectClauses = + [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.EventId))] }, + } : new ReadRawModifiedDetails + { StartTime = Epoch, EndTime = Epoch.AddMinutes(1), NumValuesPerNode = 2, IsReadModified = kind == "modified" }; + var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false, + [new HistoryReadValueId { NodeId = node, ContinuationPoint = point }], ct); + return response.Results.ToList().ShouldHaveSingleItem(); + } + + private sealed class Clock : TimeProvider + { + private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow; + private TimeSpan _offset; + public override DateTimeOffset GetUtcNow() => _initial + _offset; + public void Advance(TimeSpan amount) => _offset += amount; + public void Reset() => _offset = TimeSpan.Zero; + } + + private sealed class CountingSource : IHistorianDataSource + { + private int _calls; + public int Calls => Volatile.Read(ref _calls); + private static DataValueSnapshot Value(int i) => new((double)i, OpcUaStatus.Good, Epoch.AddSeconds(i), Epoch.AddSeconds(i)); + public Task ReadRawAsync(string reference, DateTime start, DateTime end, uint max, CancellationToken ct) + { + Interlocked.Increment(ref _calls); + var rows = Enumerable.Range(0, 25).Select(Value).Where(x => x.SourceTimestampUtc >= start && x.SourceTimestampUtc < end).ToList(); + var count = max == 0 ? rows.Count : checked((int)max); + return Task.FromResult(new CoreReadResult(rows.Take(count).ToList(), rows.Count > count ? [1] : null)); + } + public Task ReadModifiedAsync(string reference, DateTime start, DateTime end, + uint max, HistoryModifiedCursor? resume, CancellationToken ct) + { + Interlocked.Increment(ref _calls); + var rows = Enumerable.Range(0, 25).Where(i => Epoch.AddSeconds(i) >= start && Epoch.AddSeconds(i) < end + && (resume is null || i > resume.ModifiedId)) + .Select(i => new HistoryModifiedValue(Value(i), Epoch.AddMinutes(2), HistoryModificationType.Replace, + "fixture", i, Epoch.AddSeconds(i))).ToList(); + var count = max == 0 ? rows.Count : checked((int)max); + return Task.FromResult(new HistoryModifiedReadResult(rows.Take(count).ToList(), rows.Count > count)); + } + public Task ReadEventsAsync(string? source, DateTime start, DateTime end, int max, CancellationToken ct) + { + Interlocked.Increment(ref _calls); + var rows = Enumerable.Range(0, 25).Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i), Epoch, "m", 500)) + .Where(x => x.EventTimeUtc >= start && x.EventTimeUtc < end).ToList(); + var count = max <= 0 ? rows.Count : max; + return Task.FromResult(new HistoricalEventsResult(rows.Take(count).ToList(), rows.Count > count ? [1] : null)); + } + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan interval, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, interval, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList times, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, times, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs index 24ccfc1af..f2695e818 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs @@ -1,4 +1,6 @@ using System.Text; +using System.Net; +using System.Collections.Concurrent; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; @@ -29,6 +31,16 @@ public sealed class Sdk2TrustedChannelTests (await session.ReadValueAsync(rig.Node(session), ct)).WrappedValue.GetInt32().ShouldBe(7); (await rig.WriteAsync(session, 42, ct)).ShouldBe(StatusCodes.Good); rig.Writes.ShouldBe(1, "a narrow grant with no application-wide roles must permit the write"); + var observedPeer = rig.Peers.ShouldHaveSingleItem(); + observedPeer.ShouldNotBeNull(); + IPAddress.IsLoopback(observedPeer.Address).ShouldBeTrue(); + observedPeer.Port.ShouldBe(0, "the public SDK evidence supplies an IP address, not an invented client port"); + var originalSessionId = session.SessionId; + await session.ReconnectAsync(null, null, ct); + session.SessionId.ShouldBe(originalSessionId, "the test must reactivate the existing session on a new channel"); + rig.Peers.Count.ShouldBe(2); + rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(observedPeer.Address); + (await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good); var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity .ShouldBeOfType(); identity.Principal.ShouldBe(TrustedRig.Principal); @@ -80,6 +92,7 @@ public sealed class Sdk2TrustedChannelTests private int _port; private int _writes; private int _authCalls; + internal ConcurrentQueue Peers { get; } = new(); internal OtOpcUaSdkServer Server { get; private set; } = null!; internal int Writes => Volatile.Read(ref _writes); internal int AuthCalls => Volatile.Read(ref _authCalls); @@ -204,6 +217,12 @@ public sealed class Sdk2TrustedChannelTests new Dictionary> { [new(NodeAclScopeKind.RawDevice, "device")] = [new([NodeAclRealms.Raw, "Plant", "Device"])] }); + public Task AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct) + { + Peers.Enqueue(peer is null ? null : new IPEndPoint(peer.Address, peer.Port)); + return AuthenticateUserNameAsync(username, password, ct); + } + public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { Interlocked.Increment(ref _authCalls); From 73ee3ef20dd4cea42c9863eb3863c9e593c42144 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:02:25 -0500 Subject: [PATCH 5/9] test(opcua): add opt-in lifecycle and resource soak harness --- .../Sdk2LifecycleSoakTests.cs | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs new file mode 100644 index 000000000..0b6cbd262 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs @@ -0,0 +1,538 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ClientSession = Opc.Ua.Client.ISession; +using Subscription = Opc.Ua.Client.Subscription; +using MonitoredItem = Opc.Ua.Client.MonitoredItem; +using ServerSession = Opc.Ua.Server.ISession; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Opt-in Task 12 lifecycle evidence. Diagnostic mode is deliberately separate from the two-hour gate. +/// Run only this test in an otherwise idle test process; RSS/FD counts describe the whole process. +/// No key, certificate, store path, token, or OPC UA payload is written to the JSONL evidence. +/// +[Trait("Category", "E2E")] +public sealed class Sdk2LifecycleSoakTests +{ + private const string Scalar = ServerEventRig.RawDeviceFolder + "/SoakScalar"; + private const string ArrayTag = ServerEventRig.RawDeviceFolder + "/SoakArray"; + private const string UnsScalar = "SoakEquipment/Scalar"; + private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + public static bool Enabled => Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK") == "1"; + + [Fact(SkipUnless = nameof(Enabled), Skip = "Opt in with OTOPCUA_SDK2_SOAK=1 and an explicit diagnostic/full mode.")] + public async Task Repeated_lifecycles_return_resources_to_a_steady_baseline() + { + var mode = Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_MODE"); + (mode is "diagnostic" or "full").ShouldBeTrue("explicit diagnostic or full mode is required"); + var seconds = int.Parse(Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_SECONDS") + ?? (mode == "full" ? "7200" : "90"), CultureInfo.InvariantCulture); + if (mode == "full") seconds.ShouldBeGreaterThanOrEqualTo(7200); + else seconds.ShouldBeInRange(60, 120); + var output = Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_OUTPUT") + ?? throw new InvalidOperationException("An absolute, fresh OTOPCUA_SDK2_SOAK_OUTPUT JSONL path is required."); + Path.IsPathFullyQualified(output).ShouldBeTrue(); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + await using var evidence = new StreamWriter(new FileStream(output, FileMode.CreateNew, FileAccess.Write, FileShare.Read)); + using var run = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + run.CancelAfter(TimeSpan.FromSeconds(seconds + 120)); + var ct = run.Token; + var watch = Stopwatch.StartNew(); + var clientRoot = Path.Combine(Path.GetTempPath(), $"otua-soak-client-{Guid.NewGuid():N}"); + ApplicationInstance? application = null; + ServerEventRig? rig = null; + string? serverRoot = null; + var cycles = 0; + var restarts = 0; + Sample? baseline = null; + var retained = new Queue(); + try + { + var telemetry = DefaultTelemetry.Create(_ => { }); + var security = TestClientSecurity.Build(clientRoot); + security.ApplicationCertificate!.SubjectName = "CN=SDK2LifecycleSoak"; + var config = new ApplicationConfiguration(telemetry) + { + ApplicationName = "SDK2LifecycleSoak", ApplicationUri = $"urn:SDK2LifecycleSoak:{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, SecurityConfiguration = security, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15_000 }, + TransportQuotas = new TransportQuotas { OperationTimeout = 5000 }, + }; + application = new ApplicationInstance(telemetry) { ApplicationConfiguration = config }; + await config.ValidateAsync(ApplicationType.Client, ct); + await application.CheckApplicationInstanceCertificatesAsync(true, null, ct); + rig = await ServerEventRig.StartAsync("SDK2LifecycleSoak", encrypted: true, ct); + serverRoot = RequiredField(rig, "_pkiRoot"); + Configure(rig); + await LogAsync(evidence, new + { + kind = "start", utc = DateTimeOffset.UtcNow, processId = Environment.ProcessId, + mode, requestedSeconds = seconds, minimumFullSeconds = 7200, + build = Environment.GetEnvironmentVariable("OTOPCUA_SOAK_BUILD_SHA"), + sdk = typeof(OperationContext).Assembly.GetCustomAttribute()?.InformationalVersion, + diagnosticInstrumentation = "SDK2.0.1 CP lists and certificate-validation cached stores; read-only", + limits = new { retainedManagedGrowthBytes = 64L << 20, rssGrowthBytes = 128L << 20, fdGrowth = 32 }, + coverage = "encrypted sessions; scalar/Int16[] Raw+UNS; subscription removal; browse abandonment; history release/cross-session/close; surgical changes/rebuild; revocation; paused TCP read/recovery; same-PKI restart", + }); + while (watch.Elapsed < TimeSpan.FromSeconds(seconds)) + { + var cycle = Stopwatch.StartNew(); + await ExerciseAsync(rig, config, ++cycles, evidence, ct); + await EventuallyAsync(() => PublicCounts(rig) == (0, 0, 0), ct); + var sample = Capture(rig, config.CertificateManager, watch.Elapsed, cycles, clientRoot, serverRoot); + sample.Sessions.ShouldBe(0); sample.Subscriptions.ShouldBe(0); sample.Items.ShouldBe(0); + sample.BrowsePoints.ShouldBe(0); sample.HistoryPoints.ShouldBe(0); + sample.LiveDataItems.ShouldBe(0); sample.LiveEventItems.ShouldBe(0); sample.MonitoredNodes.ShouldBe(0); + sample.PkiDescriptors.ShouldBe(0, "quiescent certificate file descriptors must be closed"); + if (cycles >= 3) + { + baseline ??= sample; + sample.CachedCertificateStores.ShouldBe(baseline.CachedCertificateStores); + sample.Variables.ShouldBe(baseline.Variables); sample.Folders.ShouldBe(baseline.Folders); + sample.Conditions.ShouldBe(baseline.Conditions); + sample.Descriptors.ShouldBeLessThanOrEqualTo(baseline.Descriptors + 32); + sample.ManagedBytes.ShouldBeLessThanOrEqualTo(baseline.ManagedBytes + (64L << 20)); + sample.RssBytes.ShouldBeLessThanOrEqualTo(baseline.RssBytes + (128L << 20)); + retained.Enqueue(sample); + if (retained.Count > 24) retained.Dequeue(); + if (retained.Count == 24) + { + var window = retained.ToArray(); + var strictlyIncreasing = window.Zip(window.Skip(1)).All(pair => pair.Second.ManagedBytes > pair.First.ManagedBytes); + (strictlyIncreasing && window[^1].ManagedBytes - window[0].ManagedBytes > (16L << 20)).ShouldBeFalse( + "retained managed memory must not grow monotonically across 24 quiescent cycles"); + } + } + await LogAsync(evidence, new { kind = "quiescent", cycleSeconds = cycle.Elapsed.TotalSeconds, resources = sample }); + if (cycles % 4 == 0) + { + var restart = Stopwatch.StartNew(); + await RestartWithActiveResourcesAsync(rig, config, ct).WaitAsync(TimeSpan.FromSeconds(25), ct); + Configure(rig); + restarts++; + await LogAsync(evidence, new { kind = "restart", cycles, restarts, seconds = restart.Elapsed.TotalSeconds }); + } + } + cycles.ShouldBeGreaterThanOrEqualTo(4, "diagnostic mode must exercise at least one actual restart"); + restarts.ShouldBeGreaterThan(0); + watch.Elapsed.TotalSeconds.ShouldBeGreaterThanOrEqualTo(seconds); + var finalClientCores = CertificateCores(config.CertificateManager); + var finalServerCores = CertificateCores(rig.Server.CertificateManager!); + await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct); + rig = null; + await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct); + application = null; + finalClientCores.Sum(CachedStores).ShouldBe(0); + finalServerCores.Sum(CachedStores).ShouldBe(0); + DescriptorCounts(clientRoot, serverRoot).Pki.ShouldBe(0); + await LogAsync(evidence, new { kind = "complete", utc = DateTimeOffset.UtcNow, mode, cycles, restarts, seconds = watch.Elapsed.TotalSeconds, + satisfiesTwoHourDuration = mode == "full" && watch.Elapsed.TotalSeconds >= 7200 }); + } + catch (Exception error) + { + await LogAsync(evidence, new { kind = "failed", cycles, restarts, seconds = watch.Elapsed.TotalSeconds, + exceptionType = error.GetType().FullName }); + throw; + } + finally + { + if (rig is not null) await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken); + if (application is not null) await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken); + if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true); + if (serverRoot is not null) Directory.Exists(serverRoot).ShouldBeFalse("the task-owned server PKI must be released"); + } + } + + private static void Configure(ServerEventRig rig) + { + rig.MaterialiseNative(); + rig.Nm.HistorianDataSource = new EventHistoryPagingOverTheWireTests.SqlLikeEventSource(Enumerable.Range(0, 40) + .Select(i => new HistoricalEvent($"soak-{i}", "source", Epoch.AddSeconds(i), Epoch, "soak", 500)).ToList()); + rig.Nm.NodeWriteGateway = new AcceptWrites(); + MaterialiseValues(rig); + rig.Enforce(true, [ServerEventRig.Key()], Grant()); + } + + private static ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl Grant() => ServerEventRig.ClusterGrant( + ServerEventRig.KeyPrincipal, "soak-grant", NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe + | NodePermissions.WriteOperate | NodePermissions.HistoryRead); + + private static void MaterialiseValues(ServerEventRig rig) + { + rig.Sink.EnsureFolder(ServerEventRig.RawDeviceFolder, null, "Device", AddressSpaceRealm.Raw); + rig.Sink.EnsureVariable(Scalar, ServerEventRig.RawDeviceFolder, "Scalar", "Int32", true, AddressSpaceRealm.Raw); + rig.Sink.EnsureVariable(ArrayTag, ServerEventRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw, + isArray: true, arrayLength: 4); + rig.Sink.EnsureFolder("SoakEquipment", null, "Equipment", AddressSpaceRealm.Uns); + rig.Sink.EnsureVariable(UnsScalar, "SoakEquipment", "Scalar", "Int32", true, AddressSpaceRealm.Uns); + for (var i = 0; i < 16; i++) rig.Sink.EnsureVariable($"{ServerEventRig.RawDeviceFolder}/Browse{i}", + ServerEventRig.RawDeviceFolder, $"Browse{i}", "Int32", false, AddressSpaceRealm.Raw); + rig.Sink.WriteValue(Scalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + rig.Sink.WriteValue(ArrayTag, new short[] { 0, 1, 2, 3 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + rig.Sink.WriteValue(UnsScalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + } + + private static async Task ExerciseAsync(ServerEventRig rig, ApplicationConfiguration config, int cycle, + StreamWriter evidence, CancellationToken ct) + { + await using var proxy = new PauseProxy(new Uri(rig.EndpointUrl).Port); + using var owner = await ConnectAsync(rig, config, proxy.Port, ct); + using var other = await ConnectAsync(rig, config, null, ct); + var ownedSessions = rig.Server.CurrentInstance.SessionManager.GetSessions().ToArray(); + ownedSessions.Length.ShouldBe(2); + var scalar = Raw(owner, Scalar); + var array = Raw(owner, ArrayTag); + var unsNs = owner.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + unsNs.ShouldBeGreaterThan(0); + var uns = new NodeId(UnsScalar, (ushort)unsNs); + owner.NamespaceUris.GetString(scalar.NamespaceIndex).ShouldBe(V3NodeIds.RawNamespaceUri); + owner.NamespaceUris.GetString(uns.NamespaceIndex).ShouldBe(V3NodeIds.UnsNamespaceUri); + using var subscription = new Subscription(owner.DefaultSubscription) { PublishingInterval = 100, KeepAliveCount = 10 }; + owner.AddSubscription(subscription); + try + { + await subscription.CreateAsync(ct); + var seen = new ConcurrentDictionary(); + foreach (var id in new[] { scalar, array, uns }) + { + var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = id, AttributeId = Attributes.Value, + SamplingInterval = 50, QueueSize = 10 }; + item.Notification += (sender, _) => + { + foreach (var value in sender.DequeueValues()) if (StatusCode.IsGood(value.StatusCode)) seen[id] = value.WrappedValue; + }; + subscription.AddItem(item); + } + await subscription.ApplyChangesAsync(ct); + subscription.MonitoredItems.ShouldAllBe(item => ServiceResult.IsGood(item.Status.Error)); + PublicCounts(rig).ShouldBe((2, 1, 3)); + var values = new short[] { (short)(cycle % 30000), 2, 3, 4 }; + var writes = await owner.WriteAsync(null, new[] + { + new WriteValue { NodeId = scalar, AttributeId = Attributes.Value, Value = new DataValue(Variant.From(cycle)) }, + new WriteValue { NodeId = array, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(values)) }, + new WriteValue { NodeId = uns, AttributeId = Attributes.Value, Value = new DataValue(Variant.From(cycle)) }, + }, ct); + writes.Results.ToList().ShouldAllBe(status => status == StatusCodes.Good); + (await owner.ReadValueAsync(scalar, ct)).WrappedValue.GetInt32().ShouldBe(cycle); + (await owner.ReadValueAsync(array, ct)).ClrValue().ShouldBeOfType().ShouldBe(values); + (await owner.ReadValueAsync(uns, ct)).WrappedValue.GetInt32().ShouldBe(cycle); + await EventuallyAsync(() => seen.TryGetValue(scalar, out var value) && value.GetInt32() == cycle + && seen.ContainsKey(array) && seen.TryGetValue(uns, out var mirror) && mirror.GetInt32() == cycle, ct); + + // One explicitly released and one deliberately abandoned browse point. + var browse = await BrowseAsync(owner, ct); + browse.IsEmpty.ShouldBeFalse(); + (await owner.BrowseNextAsync(null, true, [browse], ct)).Results[0].StatusCode.ShouldBe(StatusCodes.Good); + (await BrowseAsync(owner, ct)).IsEmpty.ShouldBeFalse(); + // Production history points: cross-session denial, single-use resume, explicit release and close cleanup. + var first = (await PageAsync(owner, default, false, ct)).ContinuationPoint; + first.IsEmpty.ShouldBeFalse(); + (await PageAsync(other, first, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); + var next = (await PageAsync(owner, first, false, ct)).ContinuationPoint; + next.IsEmpty.ShouldBeFalse(); + (await PageAsync(owner, next, true, ct)).StatusCode.ShouldBe(StatusCodes.Good); + for (var i = 0; i < 6; i++) (await PageAsync(owner, default, false, ct)).ContinuationPoint.IsEmpty.ShouldBeFalse(); + var held = ownedSessions.Select(ContinuationCounts).ToArray(); + held.Sum(count => count.Browse).ShouldBeGreaterThan(0); + held.Sum(count => count.History).ShouldBeGreaterThan(0); + var active = PublicCounts(rig); + await LogAsync(evidence, new { kind = "active", cycle, sessions = active.Sessions, + subscriptions = active.Subscriptions, items = active.Items, browsePoints = held.Sum(x => x.Browse), historyPoints = held.Sum(x => x.History) }); + + rig.Nm.UpdateTagAttributes(Scalar, false, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue(); + rig.Nm.UpdateTagAttributes(Scalar, true, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue(); + rig.Nm.RemoveVariableNode(ArrayTag, AddressSpaceRealm.Raw).ShouldBeTrue(); + rig.Sink.EnsureVariable(ArrayTag, ServerEventRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw, + isArray: true, arrayLength: 4); + rig.Sink.WriteValue(ArrayTag, values, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + var authority = rig.Nm.DataPlaneAuthority.ShouldBeOfType(); + authority.Apply(ServerEventRig.Cluster, $"revoked-{cycle}", [], [Grant()], + new Dictionary>()); + var denied = await owner.ReadAsync(null, 0, TimestampsToReturn.Neither, + [new ReadValueId { NodeId = scalar, AttributeId = Attributes.Value }], ct); + denied.Results[0].StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + rig.Enforce(true, [ServerEventRig.Key()], Grant()); + + proxy.Pause(); + var stalled = Stopwatch.StartNew(); + string stalledStatus; + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(1)); + await owner.ReadValueAsync(scalar, timeout.Token).WaitAsync(TimeSpan.FromSeconds(5), ct); + throw new InvalidOperationException("The deliberately paused network unexpectedly completed the read."); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { stalledStatus = "OperationCanceled"; } + // Exact-tag ChannelAsyncOperation maps a canceled completion waiter to BadRequestInterrupted. + catch (ServiceResultException error) when (error.StatusCode == StatusCodes.BadTimeout + || error.StatusCode == StatusCodes.BadRequestCancelledByClient || error.StatusCode == StatusCodes.BadRequestTimeout + || error.StatusCode == StatusCodes.BadRequestInterrupted) { stalledStatus = error.StatusCode.ToString(); } + finally { proxy.Resume(); } + (await owner.ReadValueAsync(scalar, ct)).StatusCode.ShouldBe(StatusCodes.Good); + await LogAsync(evidence, new { kind = "network-stall-recovered", cycle, status = stalledStatus, seconds = stalled.Elapsed.TotalSeconds }); + await subscription.DeleteAsync(true, ct); + await owner.RemoveSubscriptionAsync(subscription, ct); + rig.Sink.RebuildAddressSpace(); + Configure(rig); + } + finally + { + proxy.Resume(); + await owner.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct); + await other.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct); + foreach (var closed in ownedSessions) + { + ContinuationCounts(closed).ShouldBe((0, 0)); + } + } + } + + private static async Task RestartWithActiveResourcesAsync(ServerEventRig rig, ApplicationConfiguration config, + CancellationToken ct) + { + using var client = await ConnectAsync(rig, config, null, ct); + using var subscription = new Subscription(client.DefaultSubscription) { PublishingInterval = 100 }; + client.AddSubscription(subscription); + await subscription.CreateAsync(ct); + subscription.AddItem(new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = Raw(client, Scalar), AttributeId = Attributes.Value, SamplingInterval = 50, + }); + await subscription.ApplyChangesAsync(ct); + PublicCounts(rig).ShouldBe((1, 1, 1)); + (await BrowseAsync(client, ct)).IsEmpty.ShouldBeFalse(); + (await PageAsync(client, default, false, ct)).ContinuationPoint.IsEmpty.ShouldBeFalse(); + var oldInternal = rig.Server.CurrentInstance; + var oldSessions = oldInternal.SessionManager.GetSessions().ToArray(); + var oldSubscriptions = oldInternal.SubscriptionManager.GetSubscriptions().ToArray(); + var oldManager = rig.Server.CertificateManager!; + var oldCores = CertificateCores(oldManager); + await rig.RestartAsync(ct).WaitAsync(TimeSpan.FromSeconds(20), ct); + oldInternal.SessionManager.GetSessions().ShouldBeEmpty(); + oldInternal.SubscriptionManager.GetSubscriptions().ShouldBeEmpty(); + oldSubscriptions.ShouldAllBe(item => item.MonitoredItemCount == 0); + foreach (var session in oldSessions) ContinuationCounts(session).ShouldBe((0, 0)); + RequiredField(oldManager, "m_disposed").ShouldBeTrue(); + oldCores.Sum(CachedStores).ShouldBe(0, "stopped server validators must release every cached store"); + PublicCounts(rig).ShouldBe((0, 0, 0)); + } + + private static async Task ConnectAsync(ServerEventRig rig, ApplicationConfiguration config, + int? proxyPort, CancellationToken ct) + { + var telemetry = DefaultTelemetry.Create(_ => { }); + var endpoint = await CoreClientUtils.SelectEndpointAsync(config, rig.EndpointUrl, true, telemetry, ct) + ?? throw new InvalidOperationException("No encrypted endpoint."); + endpoint.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); + if (proxyPort is { } port) endpoint.EndpointUrl = $"opc.tcp://127.0.0.1:{port}/OtOpcUa"; + return await new DefaultSessionFactory(telemetry).CreateAsync(config, + new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, false, + "SDK2LifecycleSoak", 15_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(ServerEventRig.KeyPassword)), default, ct); + } + + private static NodeId Raw(ClientSession session, string id) + { + var ns = session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + ns.ShouldBeGreaterThan(0); + return new NodeId(id, (ushort)ns); + } + + private static async Task BrowseAsync(ClientSession session, CancellationToken ct) => + (await session.BrowseAsync(null, null, 1, [new BrowseDescription + { + NodeId = Raw(session, ServerEventRig.RawDeviceFolder), BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, + ResultMask = (uint)BrowseResultMask.All, + }], ct)).Results[0].ContinuationPoint; + + private static async Task PageAsync(ClientSession session, ByteString point, + bool release, CancellationToken ct) => (await session.HistoryReadAsync(null, new ExtensionObject(new ReadEventDetails + { + StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 4, + Filter = new EventFilter { SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, + QualifiedName.From(BrowseNames.EventId))] }, + }), TimestampsToReturn.Both, release, + [new HistoryReadValueId { NodeId = Raw(session, ServerEventRig.RawDeviceFolder), ContinuationPoint = point }], ct)).Results[0]; + + private static (int Sessions, int Subscriptions, int Items) PublicCounts(ServerEventRig rig) + { + var subscriptions = rig.Server.CurrentInstance.SubscriptionManager.GetSubscriptions(); + return (rig.Server.CurrentInstance.SessionManager.GetSessions().Count, subscriptions.Count, + subscriptions.Sum(subscription => subscription.MonitoredItemCount)); + } + + // Exact-tag diagnostic instrumentation only. Fail if fields/types change; never replace or mutate a store. + private static (int Browse, int History) ContinuationCounts(ServerSession session) + { + var points = session.ContinuationPoints; + using (RequiredField(points, "m_lock").EnterScope()) + return (NullableCollectionCount(points, "m_browse"), NullableCollectionCount(points, "m_history")); + } + + private static object[] CertificateCores(object manager) + { + using (RequiredField(manager, "m_certificatesLock").EnterScope()) + { + var cores = new List(); + foreach (var field in new[] { "m_peerCore", "m_userCore", "m_httpsCore" }) + if (Field(manager, field).GetValue(manager) is { } core) cores.Add(core); + cores.AddRange(RequiredField(manager, "m_customCores").Values.Cast()); + return cores.Distinct(System.Collections.Generic.ReferenceEqualityComparer.Instance).ToArray(); + } + } + + private static int CachedStores(object core) => RequiredField(core, "m_stores").Count; + private static int NullableCollectionCount(object owner, string name) => Field(owner, name).GetValue(owner) switch + { + null => 0, + ICollection items => items.Count, + _ => throw new InvalidOperationException($"SDK diagnostic field {name} no longer has the expected collection type."), + }; + private static FieldInfo Field(object owner, string name) => owner.GetType().GetField(name, + BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Missing SDK diagnostic field {name}."); + private static T RequiredField(object owner, string name) => Field(owner, name).GetValue(owner) is T value ? value + : throw new InvalidOperationException($"SDK diagnostic field {name} no longer has the expected type {typeof(T).Name}."); + + private sealed record Sample(int Cycle, double Seconds, int Sessions, int Subscriptions, int Items, + int BrowsePoints, int HistoryPoints, int LiveDataItems, int LiveEventItems, int MonitoredNodes, + int Variables, int Folders, int Conditions, int CachedCertificateStores, + int Descriptors, int PkiDescriptors, long ManagedBytes, long RssBytes, long AllocatedBytes, + int Gen0, int Gen1, int Gen2); + + private static Sample Capture(ServerEventRig rig, object clientManager, TimeSpan elapsed, int cycle, + string clientRoot, string serverRoot) + { + var counts = PublicCounts(rig); + var points = rig.Server.CurrentInstance.SessionManager.GetSessions().Select(ContinuationCounts).ToArray(); + var descriptors = DescriptorCounts(clientRoot, serverRoot); + var retained = GC.GetTotalMemory(forceFullCollection: true); + using var process = Process.GetCurrentProcess(); + process.Refresh(); + return new Sample(cycle, elapsed.TotalSeconds, counts.Sessions, counts.Subscriptions, counts.Items, + points.Sum(x => x.Browse), points.Sum(x => x.History), rig.Nm.LiveItemCount, rig.Nm.LiveEventItemCount, + rig.Nm.MonitoredNodeCount, rig.Nm.VariableCount, rig.Nm.FolderCount, rig.Nm.AlarmConditionCount, + CertificateCores(rig.Server.CertificateManager!).Sum(CachedStores) + CertificateCores(clientManager).Sum(CachedStores), + descriptors.Total, descriptors.Pki, retained, process.WorkingSet64, GC.GetTotalAllocatedBytes(), + GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2)); + } + + private static (int Total, int Pki) DescriptorCounts(params string[] roots) + { + if (OperatingSystem.IsLinux()) + { + var targets = Directory.EnumerateFileSystemEntries("/proc/self/fd").Select(path => new FileInfo(path).LinkTarget).ToArray(); + return (targets.Length, targets.Count(path => path is not null && roots.Any(root => path.StartsWith(root, StringComparison.Ordinal)))); + } + if (OperatingSystem.IsMacOS()) + { + using var process = new Process { StartInfo = new ProcessStartInfo("/usr/sbin/lsof") + { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false } }; + foreach (var arg in new[] { "-a", "-p", Environment.ProcessId.ToString(CultureInfo.InvariantCulture), "-Fnf" }) + process.StartInfo.ArgumentList.Add(arg); + process.Start(); + var text = process.StandardOutput.ReadToEnd(); + _ = process.StandardError.ReadToEnd(); + if (!process.WaitForExit(5000)) { process.Kill(); throw new TimeoutException("Descriptor probe stalled."); } + process.ExitCode.ShouldBe(0); + var lines = text.Split('\n'); + return (lines.Count(line => line.Length > 1 && line[0] == 'f' && char.IsDigit(line[1])), + lines.Count(line => line.StartsWith('n') && roots.Any(root => line.AsSpan(1).StartsWith(root, StringComparison.Ordinal)))); + } + throw new PlatformNotSupportedException("This soak requires Linux procfs or macOS lsof for actual descriptor evidence."); + } + + private static async Task EventuallyAsync(Func condition, CancellationToken ct) + { + var deadline = Stopwatch.StartNew(); + while (!condition() && deadline.Elapsed < TimeSpan.FromSeconds(10)) await Task.Delay(25, ct); + condition().ShouldBeTrue("resource or notification condition did not settle within ten seconds"); + } + private static async Task LogAsync(StreamWriter output, object value) + { + await output.WriteLineAsync(JsonSerializer.Serialize(value)); + await output.FlushAsync(); + } + private sealed class AcceptWrites : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string id, object? value, AddressSpaceRealm realm, CancellationToken ct) => + Task.FromResult(new NodeWriteOutcome(true, null)); + } + + /// Task-owned TCP relay. Pause blocks forwarding in both directions without closing either socket. + private sealed class PauseProxy : IAsyncDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly ConcurrentBag _sockets = []; + private readonly ConcurrentBag _pumps = []; + private readonly Task _accept; + private TaskCompletionSource? _pause; + internal int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + internal PauseProxy(int targetPort) + { + _listener.Start(); + _accept = AcceptAsync(targetPort); + } + internal void Pause() => Volatile.Write(ref _pause, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + internal void Resume() => Interlocked.Exchange(ref _pause, null)?.TrySetResult(); + private async Task AcceptAsync(int port) + { + try + { + while (!_stop.IsCancellationRequested) + { + var incoming = await _listener.AcceptTcpClientAsync(_stop.Token); + _sockets.Add(incoming); + var outgoing = new TcpClient(); _sockets.Add(outgoing); + await outgoing.ConnectAsync(IPAddress.Loopback, port, _stop.Token); + _pumps.Add(PumpAsync(incoming.GetStream(), outgoing.GetStream())); + _pumps.Add(PumpAsync(outgoing.GetStream(), incoming.GetStream())); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } + } + private async Task PumpAsync(NetworkStream source, NetworkStream destination) + { + var buffer = new byte[16 * 1024]; + try + { + while (true) + { + var length = await source.ReadAsync(buffer, _stop.Token); + if (length == 0) return; + if (Volatile.Read(ref _pause) is { } paused) await paused.Task.WaitAsync(_stop.Token); + await destination.WriteAsync(buffer.AsMemory(0, length), _stop.Token); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } + catch (IOException) when (_stop.IsCancellationRequested) { } + } + public async ValueTask DisposeAsync() + { + Resume(); _stop.Cancel(); _listener.Stop(); + foreach (var socket in _sockets) socket.Dispose(); + try { await _accept; await Task.WhenAll(_pumps); } + finally { _stop.Dispose(); } + } + } +} From a456d598ab197afaf07f7cd9d50ceb335590d6d7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:03:52 -0500 Subject: [PATCH 6/9] test(opcua): retain manager references across soak restart --- .../Sdk2LifecycleSoakTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs index 0b6cbd262..492d07b18 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs @@ -323,13 +323,15 @@ public sealed class Sdk2LifecycleSoakTests (await BrowseAsync(client, ct)).IsEmpty.ShouldBeFalse(); (await PageAsync(client, default, false, ct)).ContinuationPoint.IsEmpty.ShouldBeFalse(); var oldInternal = rig.Server.CurrentInstance; - var oldSessions = oldInternal.SessionManager.GetSessions().ToArray(); - var oldSubscriptions = oldInternal.SubscriptionManager.GetSubscriptions().ToArray(); + var oldSessionManager = oldInternal.SessionManager; + var oldSubscriptionManager = oldInternal.SubscriptionManager; + var oldSessions = oldSessionManager.GetSessions().ToArray(); + var oldSubscriptions = oldSubscriptionManager.GetSubscriptions().ToArray(); var oldManager = rig.Server.CertificateManager!; var oldCores = CertificateCores(oldManager); await rig.RestartAsync(ct).WaitAsync(TimeSpan.FromSeconds(20), ct); - oldInternal.SessionManager.GetSessions().ShouldBeEmpty(); - oldInternal.SubscriptionManager.GetSubscriptions().ShouldBeEmpty(); + oldSessionManager.GetSessions().ShouldBeEmpty(); + oldSubscriptionManager.GetSubscriptions().ShouldBeEmpty(); oldSubscriptions.ShouldAllBe(item => item.MonitoredItemCount == 0); foreach (var session in oldSessions) ContinuationCounts(session).ShouldBe((0, 0)); RequiredField(oldManager, "m_disposed").ShouldBeTrue(); From 8be3bb1734c158665fde26c5abba5e73411332ca Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:06:49 -0500 Subject: [PATCH 7/9] test(opcua): measure soak observation after startup --- .../Sdk2LifecycleSoakTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs index 492d07b18..7c6820636 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs @@ -82,6 +82,8 @@ public sealed class Sdk2LifecycleSoakTests rig = await ServerEventRig.StartAsync("SDK2LifecycleSoak", encrypted: true, ct); serverRoot = RequiredField(rig, "_pkiRoot"); Configure(rig); + // The required observation period excludes initial certificate creation and server startup. + watch.Restart(); await LogAsync(evidence, new { kind = "start", utc = DateTimeOffset.UtcNow, processId = Environment.ProcessId, From 41e65f6a2297995de4aa325c780619d7d592a0aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:13:56 -0500 Subject: [PATCH 8/9] fix(opcua): restore alarm component reference after SDK creation --- .../OtOpcUaNodeManager.cs | 7 +- .../Sdk2AlarmBrowseCompatibilityTests.cs | 71 +++++++++++++++++++ .../NodeManagerAlarmSourceFieldsTests.cs | 25 +++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 921178491..12a20bf83 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1533,9 +1533,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 alarm.ShelvingState = new ShelvedStateMachineState(alarm); alarm.MaxTimeShelved = new PropertyState.Implementation(alarm); alarm.SymbolicName = displayName; - // HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern). - alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; - // Create builds the full mandatory Part 9 child set (EnabledState, AckedState, // ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the // type's embedded definition; we do not hand-build them. @@ -1545,6 +1542,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 new QualifiedName(displayName, ns), new LocalizedText(displayName), assignNodeIds: true); + // Create initializes the embedded definition and resets ReferenceTypeId in SDK 2.0.1. + // Restore the application's parent ownership before AddChild: hierarchical folder browses + // must expose the alarm as a HasComponent object, as they did before the SDK upgrade. + alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; // Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState -> // UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs new file mode 100644 index 000000000..da18114d2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs @@ -0,0 +1,71 @@ +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; + +/// Preserves baseline alarm discovery through hierarchical folder browses in both application realms. +public sealed class Sdk2AlarmBrowseCompatibilityTests +{ + [Theory] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Raw)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Raw)] + [InlineData("AlarmCondition", AddressSpaceRealm.Raw)] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Uns)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Uns)] + [InlineData("AlarmCondition", AddressSpaceRealm.Uns)] + public async Task Alarm_is_a_hierarchical_component_with_the_same_inverse_parent_and_object_type( + string type, AddressSpaceRealm realm) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("AlarmBrowseCompatibility", false, ct); + rig.Enforce(false, [ServerEventRig.Key()]); + rig.Sink.EnsureFolder("browse-parent", null, "Parent", realm); + rig.Sink.MaterialiseAlarmCondition("browse-alarm", "browse-parent", "Alarm", type, 500, realm, + isNative: realm == AddressSpaceRealm.Raw); + using var session = await rig.ConnectAsync(true, ct); + var uri = realm == AddressSpaceRealm.Raw ? V3NodeIds.RawNamespaceUri : V3NodeIds.UnsNamespaceUri; + var ns = session.NamespaceUris.GetIndex(uri); + ns.ShouldBeGreaterThan(0); + var parent = new NodeId("browse-parent", (ushort)ns); + var alarm = new NodeId("browse-alarm", (ushort)ns); + var expectedType = type switch + { + "OffNormalAlarm" => ObjectTypeIds.OffNormalAlarmType, + "DiscreteAlarm" => ObjectTypeIds.DiscreteAlarmType, + _ => ObjectTypeIds.AlarmConditionType, + }; + + var children = await BrowseAsync(session, parent, ReferenceTypeIds.HierarchicalReferences, BrowseDirection.Forward, true, ct); + var discovered = children.SingleOrDefault(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == alarm); + discovered.ShouldNotBeNull("the baseline folder browse exposes every materialised alarm object"); + discovered.ReferenceTypeId.ShouldBe(ReferenceTypeIds.HasComponent); + discovered.IsForward.ShouldBeTrue(); + discovered.NodeClass.ShouldBe(NodeClass.Object); + ExpandedNodeId.ToNodeId(discovered.TypeDefinition, session.NamespaceUris).ShouldBe(expectedType); + + var components = await BrowseAsync(session, parent, ReferenceTypeIds.HasComponent, BrowseDirection.Forward, false, ct); + components.ShouldContain(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == alarm); + var inverse = await BrowseAsync(session, alarm, ReferenceTypeIds.HasComponent, BrowseDirection.Inverse, false, ct); + inverse.ShouldContain(reference => !reference.IsForward + && ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == parent); + var types = await BrowseAsync(session, alarm, ReferenceTypeIds.HasTypeDefinition, BrowseDirection.Forward, false, ct); + types.ShouldContain(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == expectedType); + await session.CloseAsync(ct); + } + + private static async Task> BrowseAsync(ISession session, NodeId node, + NodeId reference, BrowseDirection direction, bool subtypes, CancellationToken ct) + { + var result = (await session.BrowseAsync(null, null, 0, [new BrowseDescription + { + NodeId = node, ReferenceTypeId = reference, BrowseDirection = direction, IncludeSubtypes = subtypes, + ResultMask = (uint)BrowseResultMask.All, + }], ct)).Results[0]; + result.StatusCode.ShouldBe(StatusCodes.Good); + result.ContinuationPoint.IsEmpty.ShouldBeTrue(); + return result.References.ToList(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs index b3cee68eb..89ab4f6b4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs @@ -41,6 +41,31 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable Path.GetTempPath(), $"otopcua-alarm-src-fields-{Guid.NewGuid():N}"); + [Theory] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Raw)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Raw)] + [InlineData("AlarmCondition", AddressSpaceRealm.Raw)] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Uns)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Uns)] + [InlineData("AlarmCondition", AddressSpaceRealm.Uns)] + public async Task Created_alarm_retains_the_parent_component_reference(string type, AddressSpaceRealm realm) + { + await using var host = await BootAsync(); + var nm = host.Nm; + nm.EnsureFolder("browse-parent", null, "Parent", realm); + nm.MaterialiseAlarmCondition("browse-alarm", "browse-parent", "Alarm", type, 500, realm, isNative: realm == AddressSpaceRealm.Raw); + var condition = nm.TryGetAlarmCondition("browse-alarm", realm)!; + condition.ReferenceTypeId.ShouldBe(ReferenceTypeIds.HasComponent); + condition.Parent!.NodeId.ShouldBe(new NodeId("browse-parent", nm.NamespaceIndexForRealm(realm))); + condition.TypeDefinitionId.ShouldBe(type switch + { + "OffNormalAlarm" => ObjectTypeIds.OffNormalAlarmType, + "DiscreteAlarm" => ObjectTypeIds.DiscreteAlarmType, + _ => ObjectTypeIds.AlarmConditionType, + }); + condition.NodeClass.ShouldBe(NodeClass.Object); + } + /// 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. From 2d75cecbb966b25ba75131be9ca8a5e39f3b5cf5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:18:15 -0500 Subject: [PATCH 9/9] test: add isolated cross-version application compatibility harness --- scripts/e2e/test-opcua-sdk-compatibility.ps1 | 22 ++ .../opcua-sdk-compatibility/ProcessHost.cs | 374 ++++++++++++++++++ .../opcua-sdk-compatibility/README.md | 96 +++++ .../opcua-sdk-compatibility/container-leg.sh | 34 ++ .../opcua-sdk-compatibility/performance.py | 115 ++++++ .../validation/opcua-sdk-compatibility/run.py | 194 +++++++++ 6 files changed, 835 insertions(+) create mode 100644 scripts/e2e/test-opcua-sdk-compatibility.ps1 create mode 100644 scripts/validation/opcua-sdk-compatibility/ProcessHost.cs create mode 100644 scripts/validation/opcua-sdk-compatibility/README.md create mode 100644 scripts/validation/opcua-sdk-compatibility/container-leg.sh create mode 100644 scripts/validation/opcua-sdk-compatibility/performance.py create mode 100644 scripts/validation/opcua-sdk-compatibility/run.py diff --git a/scripts/e2e/test-opcua-sdk-compatibility.ps1 b/scripts/e2e/test-opcua-sdk-compatibility.ps1 new file mode 100644 index 000000000..49f92a609 --- /dev/null +++ b/scripts/e2e/test-opcua-sdk-compatibility.ps1 @@ -0,0 +1,22 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Runs the production application SDK compatibility matrix in isolated processes. +.DESCRIPTION +Uses exact baseline host/client publish artifacts and builds the candidate from a +separate checkout. Both SDK versions remain in separate processes. Evidence, +artifact SHA-256 hashes, negotiated policies and individual results are retained. +The output directory must not exist. No fleet endpoint or store is used. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$BaselineRoot, + [Parameter(Mandatory)][string]$BaselineArtifacts, + [Parameter(Mandatory)][string]$CandidateRoot, + [Parameter(Mandatory)][string]$OutputDirectory, + [string]$ContainerImage = 'mcr.microsoft.com/dotnet/sdk:10.0.103-noble' +) +$ErrorActionPreference = 'Stop' +$runner = Join-Path $PSScriptRoot '../validation/opcua-sdk-compatibility/run.py' +& python3 $runner --baseline-root $BaselineRoot --baseline-artifacts $BaselineArtifacts --candidate-root $CandidateRoot --output $OutputDirectory --container-image $ContainerImage +exit $LASTEXITCODE diff --git a/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs new file mode 100644 index 000000000..22a097f55 --- /dev/null +++ b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs @@ -0,0 +1,374 @@ +#pragma warning disable OTOPCUA0001 // This wire harness deliberately exercises public driver capabilities directly. +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using ZB.MOM.WW.OtOpcUa.Client.Shared; +using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; +using AlarmEventArgs = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.AlarmEventArgs; +using WriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest; +using SharedSecurityMode = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.SecurityMode; +using CoreHistory = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +internal static class ProcessHost +{ + static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + const string RawUri = "https://zb.com/otopcua/raw", UnsUri = "https://zb.com/otopcua/uns"; + const string Folder = "Matrix/dev", Scalar = Folder + "/Scalar", Vector = Folder + "/Vector", Alarm = Folder + "/Alarm"; + static readonly CancellationToken Ct = new CancellationTokenSource(TimeSpan.FromMinutes(4)).Token; + static string Password => Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing ephemeral credential"); + static readonly List Cases = []; + static readonly List Observations = []; + static int Failures; + static string Root = ""; + static object? Clr(DataValue v) + { +#if SDK2 + return ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(v.WrappedValue); +#else + return v.Value; +#endif + } + static DateTime SourceTime(DataValue value) + { +#if SDK2 + return value.SourceTimestamp.ToDateTime(); +#else + return value.SourceTimestamp; +#endif + } + static DateTime ServerTime(DataValue value) + { +#if SDK2 + return value.ServerTimestamp.ToDateTime(); +#else + return value.ServerTimestamp; +#endif + } + static void Require(bool yes, string message) { if (!yes) throw new InvalidOperationException(message); } + static async Task Case(string name, Func action) + { + try { await action(); Cases.Add(new { name, status = "passed" }); } + catch (Exception e) { Failures++; Cases.Add(new { name, status = "failed", error = e.ToString() }); } + } + static async Task Wait(Func predicate) + { + for (int i = 0; i < 200; i++) { if (predicate()) return; await Task.Delay(50, Ct); } + Require(false, "Notification deadline exceeded"); + } + static ConnectionSettings Settings(string endpoint) => new() + { + EndpointUrl = endpoint, + Username = "apikey", + Password = Password, + SecurityMode = SharedSecurityMode.SignAndEncrypt, + AutoAcceptCertificates = false, + CertificateStorePath = Environment.GetEnvironmentVariable("OPCUA_MATRIX_CLIENT_PKI") ?? Path.Combine(Root, "client-pki") + }; + public static async Task Main(string[] args) + { + Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root); + try + { + switch (args[0]) + { + case "local-data-path": Console.WriteLine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); return 0; + case "prepare": + var type = typeof(OpcUaClientService).Assembly.GetType("ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters.DefaultApplicationConfigurationFactory")!; + var factory = Activator.CreateInstance(type, true)!; + await (Task)type.GetMethod("CreateAsync")!.Invoke(factory, [Settings("opc.tcp://127.0.0.1:1"), Ct])!; + Console.WriteLine("PREPARED"); return 0; + case "prepare-driver": await PrepareDriver(); return 0; + case "driver": await Driver(args[2]); break; + case "server": await Serve(); return 0; + case "client": await Client(args[2]); break; + case "performance": await Performance(args[2]); break; + default: throw new ArgumentException("Expected prepare/server/client"); + } + } + catch (Exception e) { Failures++; Cases.Add(new { name = "process", status = "failed", error = e.ToString() }); } + File.WriteAllText(Path.Combine(Root, "result.json"), JsonSerializer.Serialize(new { sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), process = Environment.ProcessId, cases = Cases, observations = Observations, failures = Failures, loadedSdkAssemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name!.StartsWith("Opc.Ua", StringComparison.Ordinal)).Select(a => new { name = a.GetName().Name, version = a.GetName().Version?.ToString(), path = a.Location }) }, new JsonSerializerOptions { WriteIndented = true })); + return Failures == 0 ? 0 : 1; + } + static async Task Serve() + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Matrix", + ApplicationUri = $"urn:OtOpcUa.Matrix.{Guid.NewGuid():N}", + PublicHostname = "127.0.0.1", + PkiStoreRoot = Path.Combine(Root, "server-pki"), + AllowAnonymous = false, + AutoAcceptUntrustedClientCertificates = false, + EnabledSecurityProfiles = Environment.GetEnvironmentVariable("OPCUA_MATRIX_SERVER_SECURITY") == "none" + ? [OpcUaSecurityProfile.None] : [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt] + }; + using (var listener = new TcpListener(IPAddress.Loopback, 0)) { listener.Start(); options.OpcUaPort = ((IPEndPoint)listener.LocalEndpoint).Port; } + var server = new OtOpcUaSdkServer(); + await using var host = new OpcUaApplicationHost(options, NullLogger.Instance, new Auth()); + await host.StartAsync(server, Ct); + var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); + var authority = new DataPlaneAuthorityState(); + authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }], + [new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary>()); + nm.DataPlaneAuthority = authority; + nm.Authorizer = new DataPlaneAuthorizer(authority, new TriePermissionEvaluator(authority.Tries), true, NullLogger.Instance, TimeProvider.System); + var history = new Source(Root); nm.HistorianDataSource = history; + sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw); + sink.EnsureFolder("Eq", null, "Eq", AddressSpaceRealm.Uns); + foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns }) + { + var scalar = realm == AddressSpaceRealm.Raw ? Scalar : "Eq/Scalar"; + var vector = realm == AddressSpaceRealm.Raw ? Vector : "Eq/Vector"; + var parent = realm == AddressSpaceRealm.Raw ? Folder : "Eq"; + sink.EnsureVariable(scalar, parent, "Scalar", "Int32", true, realm, "matrix.scalar"); + sink.EnsureVariable(vector, parent, "Vector", "Int32", true, realm, isArray: true, arrayLength: 3); + sink.WriteValue(scalar, 17, 0, Epoch, realm); sink.WriteValue(vector, new[] { 1, 2, 3 }, 0, Epoch, realm); + } + sink.AddReference("Eq/Scalar", AddressSpaceRealm.Uns, Scalar, AddressSpaceRealm.Raw); + sink.MaterialiseAlarmCondition(Alarm, Folder, "Alarm", "OffNormalAlarm", 800, AddressSpaceRealm.Raw, isNative: true); + sink.WireAlarmNotifiers(Alarm, AddressSpaceRealm.Raw, ["Eq"], AddressSpaceRealm.Uns); + sink.EnsureVariable(Folder + "/UnsetTimestamp", Folder, "UnsetTimestamp", "Int32", false, AddressSpaceRealm.Raw); + sink.WriteValue(Folder + "/UnsetTimestamp", 3, 0, DateTime.MinValue, AddressSpaceRealm.Raw); + nm.NodeWriteGateway = new Gateway(); + var state = new AlarmConditionSnapshot(true, false, true, true, AlarmShelvingKind.Unshelved, 800, "matrix-alarm"); + nm.NativeAlarmAckRouter = _ => Task.Run(() => { state = state with { Acknowledged = true }; sink.WriteAlarmCondition(Alarm, state, DateTime.UtcNow, AddressSpaceRealm.Raw); }); + File.WriteAllText(Path.Combine(Root, "endpoint.txt"), $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa"); + File.WriteAllText(Path.Combine(Root, "ready.json"), JsonSerializer.Serialize(new { endpoint = $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa", process = Environment.ProcessId, sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), rawUri = RawUri, unsUri = UnsUri })); + int tick = 0; + while (!File.Exists(Path.Combine(Root, "stop"))) + { + sink.WriteAlarmCondition(Alarm, state with { Message = ++tick % 2 == 0 ? "matrix-alarm" : "matrix-tick" }, DateTime.UtcNow, AddressSpaceRealm.Raw); + await Task.Delay(1000, Ct); + } + using var process = System.Diagnostics.Process.GetCurrentProcess(); + File.WriteAllText(Path.Combine(Root, "server-resources.json"), JsonSerializer.Serialize(new { process.WorkingSet64, process.PrivateMemorySize64, process.HandleCount, threads=process.Threads.Count, cpuMilliseconds=process.TotalProcessorTime.TotalMilliseconds, allocatedBytes=GC.GetTotalAllocatedBytes(), collections=new[]{GC.CollectionCount(0),GC.CollectionCount(1),GC.CollectionCount(2)} }, new JsonSerializerOptions {WriteIndented=true})); + } + static async Task Client(string endpoint) + { + using var client = new OpcUaClientService(); + await Case("trusted-SignAndEncrypt-API-key", async () => { var info = await client.ConnectAsync(Settings(endpoint), Ct); Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected negotiated policy"); Observations.Add(new { info.SecurityMode, info.SecurityPolicyUri }); }); + if (!client.IsConnected) return; + var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!; + var raw = (ushort)Array.IndexOf(namespaces, RawUri); var uns = (ushort)Array.IndexOf(namespaces, UnsUri); + NodeId ScalarId(ushort ns) => new(ns == raw ? Scalar : "Eq/Scalar", ns); + NodeId VectorId(ushort ns) => new(ns == raw ? Vector : "Eq/Vector", ns); + await Case("raw-UNS-URI-browse", async () => + { + Require(raw > 0 && uns > 0, "Missing namespace URI"); + var rawChildren = await client.BrowseAsync(new NodeId(Folder, raw), Ct); + var unsChildren = await client.BrowseAsync(new NodeId("Eq", uns), Ct); + Observations.Add(new { rawChildren, unsChildren }); + // Supplement the production Shared results with unfiltered wire-reference metadata. + var adapter = typeof(OpcUaClientService).GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(client)!; + var sdkSession = (Opc.Ua.Client.Session)adapter.GetType().GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(adapter)!; + var (_, _, rawReferences) = await sdkSession.BrowseAsync(null, null, new NodeId(Folder, raw), 0u, + BrowseDirection.Forward, ReferenceTypeIds.References, true, 0u); + Observations.Add(new { rawReferences = (rawReferences.ToArray() ?? []).Select(reference => new { nodeId = reference.NodeId.ToString(), browseName = reference.BrowseName.ToString(), nodeClass = reference.NodeClass.ToString(), typeDefinition = reference.TypeDefinition.ToString(), referenceType = reference.ReferenceTypeId.ToString(), reference.IsForward }).ToArray() }); + var expectedRaw = new[] { Scalar, Vector, Alarm, Folder + "/UnsetTimestamp" }.Select(id => new NodeId(id, raw).ToString()); + Require(expectedRaw.All(id => rawChildren.Any(child => child.NodeId == id)), "Missing raw children: " + string.Join(",", expectedRaw.Where(id => !rawChildren.Any(child => child.NodeId == id)))); + Require(new[] { "Eq/Scalar", "Eq/Vector" }.Select(id => new NodeId(id, uns).ToString()).All(id => unsChildren.Any(child => child.NodeId == id)), "Missing UNS scalar/array children"); + }); + await Case("initial-quality-explicit-source-and-server-timestamps", async () => { var initial = await client.ReadValueAsync(ScalarId(raw), Ct); Require(StatusCode.IsGood(initial.StatusCode), "Bad initial quality"); Require(SourceTime(initial) == Epoch && ServerTime(initial) > Epoch, "Lost explicit initial timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(initial).ToString("O"), serverTimestamp = ServerTime(initial).ToString("O"), contract = "Source supplied as 2026-10-01T00:00:00Z; server timestamp observed without normalization or historian algorithm changes" }); }); + foreach (var ns in new[] { raw, uns }) + { + await Case($"scalar-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(ScalarId(ns), Ct)) is int, "Scalar lost Int32 type"); Require(StatusCode.IsGood(await client.WriteValueAsync(ScalarId(ns), 42, Ct)), "Scalar write failed"); Require((int)Clr(await client.ReadValueAsync(ScalarId(ns), Ct))! == 42, "Scalar round trip failed"); }); + await Case($"typed-array-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(VectorId(ns), Ct)) is int[], "Array lost Int32[] type"); Require(StatusCode.IsGood(await client.WriteValueAsync(VectorId(ns), new[] { 7, 8, 9 }, Ct)), "Array write failed"); Require(((int[])Clr(await client.ReadValueAsync(VectorId(ns), Ct))!).SequenceEqual(new[] { 7, 8, 9 }), "Array round trip failed"); }); + } + await Case("monitor-quality-timestamps", async () => { var seen = new ConcurrentQueue(); client.DataChanged += (_, e) => seen.Enqueue(e.Value); await client.SubscribeAsync(ScalarId(raw), 100, Ct); await client.WriteValueAsync(ScalarId(raw), 73, Ct); await Wait(() => seen.Any(v => Equals(Clr(v), 73))); var v = seen.Last(v => Equals(Clr(v), 73)); Require(StatusCode.IsGood(v.StatusCode) && SourceTime(v) > Epoch && ServerTime(v) > Epoch, "Bad monitor quality or missing timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(v).ToString("O"), serverTimestamp = ServerTime(v).ToString("O"), status = v.StatusCode.ToString() }); await client.UnsubscribeAsync(ScalarId(raw), Ct); }); + await Case("event-filter-and-native-ack", async () => { var alarms = new ConcurrentQueue(); client.AlarmEvent += (_, a) => alarms.Enqueue(a); await client.SubscribeAlarmsAsync(ObjectIds.Server, 100, Ct); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.ActiveState && a.Severity == 800)); var alarm = alarms.Last(a => a.Message == "matrix-alarm"); Require(alarm.EventId?.Length > 0, "Missing event ID"); Require(StatusCode.IsGood(await client.AcknowledgeAlarmAsync(new NodeId(Alarm, raw).ToString(), alarm.EventId!, "matrix-ack", Ct)), "Ack refused"); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.AckedState)); await client.UnsubscribeAlarmsAsync(Ct); }); + await Case("raw-history-CP-paging", async () => { var values = await client.HistoryReadRawAsync(ScalarId(raw), Epoch, Epoch.AddMinutes(1), 100, Ct); Require(values.Count == 25, "Expected 25 raw samples, got " + values.Count); Require(values.Select(v => Convert.ToInt32(Clr(v))).SequenceEqual(Enumerable.Range(0, 25)), "Raw page loss/duplicates/order"); }); + await Case("event-history-CP-paging", async () => + { + var filter = new EventFilter(); +#if SDK2 + filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value }; +#else + filter.SelectClauses.Add(new SimpleAttributeOperand {TypeDefinitionId=ObjectTypeIds.BaseEventType,BrowsePath=new QualifiedNameCollection {new QualifiedName("EventId")},AttributeId=Attributes.Value}); +#endif + var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct); Require(events.Count == 25, "Expected 25 event samples, got " + events.Count); +#if SDK2 + var ids = events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Fields[0])!)); +#else + var ids=events.Select(e=>System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value)); +#endif + Require(ids.SequenceEqual(Enumerable.Range(0, 25).Select(i => $"e{i:00}")), "Event history loss/duplicates/order across timestamp ties"); + }); + await Case("event-history-where-filter-positive-and-negative", async () => + { + foreach (var type in new[] { ObjectTypeIds.BaseEventType, ObjectTypeIds.AuditEventType }) + { + var filter = new EventFilter(); +#if SDK2 + filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value }; + filter.WhereClause.Push(FilterOperator.OfType, Variant.From(type)); +#else + filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType,new QualifiedName("EventId")){AttributeId=Attributes.Value}); + filter.WhereClause.Push(FilterOperator.OfType,new LiteralOperand(new Variant(type))); +#endif + var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct); + Require(events.Count == (type == ObjectTypeIds.BaseEventType ? 25 : 0), $"Where filter for {type} expected {(type == ObjectTypeIds.BaseEventType ? 25 : 0)} got {events.Count}"); + } + }); + var unset = await client.ReadValueAsync(new NodeId(Folder + "/UnsetTimestamp", raw), Ct); + Observations.Add(new { sourceTimestamp = SourceTime(unset).ToString("O"), serverTimestamp = ServerTime(unset).ToString("O"), timestampObservation = "Fixture source supplied DateTime.MinValue (unset). These are unmodified application/SDK wire observations; no historian algorithm or expected timestamp was changed." }); + await Benchmark(client, ScalarId(raw), VectorId(raw)); + await client.DisconnectAsync(Ct); + } + static OpcUaClientDriverOptions DriverOptions(string endpoint) => new() + { + EndpointUrl = endpoint, + SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256, + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, + AuthType = OpcUaAuthType.Username, + Username = "apikey", + Password = Password, + AutoAcceptCertificates = false, + RawTags = [new("scalar",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Scalar}"}),true), + new("vector",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Vector}"}),true), + new("uns",JsonSerializer.Serialize(new {nodeId=$"nsu={UnsUri};s=Eq/Scalar"}),true)], + SessionName = "MatrixDriver", + ApplicationUri = "urn:localhost:MatrixDriver" + }; + static string IsolatedLocalData() + { + var local = Path.GetFullPath(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + Require(local.StartsWith(Path.GetFullPath(Root) + Path.DirectorySeparatorChar, StringComparison.Ordinal), "Driver/CLI canonical PKI must be in task-owned root: " + local); + return local; + } + static async Task PrepareDriver() + { + var local = IsolatedLocalData(); + await using var driver = new OpcUaClientDriver(DriverOptions("opc.tcp://127.0.0.1:1"), "matrix"); + // The production configuration method creates the application's own certificate without + // attempting a session. Reflection is confined to preparation; all wire operations below + // call the unchanged public driver API. + var type = typeof(OpcUaClientDriver); + var hook = Activator.CreateInstance(type.GetNestedType("CertValidationHook", BindingFlags.NonPublic)!, true)!; + await (Task)type.GetMethod("BuildApplicationConfigurationAsync", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(driver, [hook, Ct])!; + File.WriteAllText(Path.Combine(Root, "driver-pki.txt"), Path.Combine(local, "OtOpcUa", "pki")); + } + static async Task Driver(string endpoint) + { + IsolatedLocalData(); + await using var driver = new OpcUaClientDriver(DriverOptions(endpoint), "matrix"); + await Case("driver-trusted-encrypted-API-key-connect", () => driver.InitializeAsync("{}", Ct)); + await Case("driver-raw-UNS-URI-scalar-and-array-read", async () => + { + var values = await driver.ReadAsync(["scalar", "vector", "uns"], Ct); + Require(values.Count == 3 && values.All(x => x.StatusCode == 0), "Read failure"); + Require(values[0].Value is int && values[1].Value is int[] && values[2].Value is int, "Lost scalar/array type"); + }); + await Case("driver-scalar-array-write-roundtrip", async () => + { + var writes = await driver.WriteAsync([new WriteRequest("scalar", 91), new WriteRequest("vector", new[] { 11, 12, 13 })], Ct); + Require(writes.All(x => x.StatusCode == 0), "Write failed"); + var values = await driver.ReadAsync(["scalar", "vector"], Ct); + Require(Equals(values[0].Value, 91) && ((int[])values[1].Value!).SequenceEqual(new[] { 11, 12, 13 }), "Write round trip mismatch"); + }); + await Case("driver-monitor-quality-timestamps", async () => + { + var seen = new ConcurrentQueue(); + driver.OnDataChange += (_, e) => seen.Enqueue(e.Snapshot); + var subscription = await driver.SubscribeAsync(["scalar"], TimeSpan.FromMilliseconds(100), Ct); + await driver.WriteAsync([new WriteRequest("scalar", 92)], Ct); + await Wait(() => seen.Any(x => Equals(x.Value, 92))); + var value = seen.Last(x => Equals(x.Value, 92)); + Require(value.StatusCode == 0 && value.SourceTimestampUtc.HasValue && value.ServerTimestampUtc > Epoch, "Missing monitor quality/timestamps"); + Observations.Add(new { value.SourceTimestampUtc, value.ServerTimestampUtc, value.StatusCode }); + await driver.UnsubscribeAsync(subscription, Ct); + }); + Observations.Add(new { capability = "driver-history", status = "not-applicable", evidence = "Driver has no IHistoryProvider/HistoryRead capability; .claude/rules/drivers.md #655. Shared/CLI own history." }); + await driver.ShutdownAsync(Ct); + } + static async Task Performance(string endpoint) + { + using var client = new OpcUaClientService(); + var info = await client.ConnectAsync(Settings(endpoint), Ct); + Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected benchmark endpoint policy"); + var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!; + var raw = (ushort)Array.IndexOf(namespaces, RawUri); + Require(raw > 0, "Missing benchmark raw namespace"); + Observations.Add(new {info.SecurityMode, info.SecurityPolicyUri, tieredCompilation=Environment.GetEnvironmentVariable("DOTNET_TieredCompilation"), scope="Two scalar/Int32[3] variables; zero data monitors during measurement; five fixture variables and one native condition"}); + await Case("warmed-production-client-read-write-measurement", () => Benchmark(client, new NodeId(Scalar, raw), new NodeId(Vector, raw))); + await client.DisconnectAsync(Ct); + } + static async Task Benchmark(OpcUaClientService client, NodeId scalar, NodeId vector) + { + // Local diagnostic subset; excludes reconnect, history and load. + var rounds = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_ROUNDS") ?? "3"); + var warmups = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_WARMUPS") ?? "10"); + var measured = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_MEASURED") ?? "50"); + Require(rounds > 0 && warmups >= 0 && measured > 0, "Invalid performance sample counts"); + for (int round = 1; round <= rounds; round++) + { + for (int warmup = 0; warmup < warmups; warmup++) + { + await client.ReadValueAsync(scalar, Ct); await client.WriteValueAsync(scalar, warmup, Ct); + await client.ReadValueAsync(vector, Ct); await client.WriteValueAsync(vector, new[] { warmup, warmup + 1, warmup + 2 }, Ct); + } + var samples = new Dictionary>(); + var before = System.Diagnostics.Process.GetCurrentProcess(); var cpu = before.TotalProcessorTime; + var allocated = GC.GetTotalAllocatedBytes(); var gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) }; + foreach (var operation in new[] { "scalar-read", "scalar-write", "array-read", "array-write" }) + { + var times = new List(); + for (int i = 0; i < measured; i++) + { + var start = System.Diagnostics.Stopwatch.GetTimestamp(); + if (operation == "scalar-read") await client.ReadValueAsync(scalar, Ct); + else if (operation == "scalar-write") Require(StatusCode.IsGood(await client.WriteValueAsync(scalar, i, Ct)), "Benchmark scalar write"); + else if (operation == "array-read") await client.ReadValueAsync(vector, Ct); + else Require(StatusCode.IsGood(await client.WriteValueAsync(vector, new[] { i, i + 1, i + 2 }, Ct)), "Benchmark array write"); + times.Add(System.Diagnostics.Stopwatch.GetElapsedTime(start).TotalMilliseconds); + } + samples[operation] = times; + } + before.Refresh(); + Observations.Add(new + { + benchmarkRound = round, + warmupOperationsPerKind = 10, + operationsPerKind = 50, + latencyMilliseconds = samples.ToDictionary(x => x.Key, x => new { p50 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.50) - 1), p95 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.95) - 1), p99 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.99) - 1), throughputOperationsPerSecond = measured * 1000.0 / x.Value.Sum() }), + clientCpuMilliseconds = (before.TotalProcessorTime - cpu).TotalMilliseconds, + clientAllocatedBytes = GC.GetTotalAllocatedBytes() - allocated, clientAllocatedBytesPerOperation = (GC.GetTotalAllocatedBytes() - allocated) / (4.0 * measured), + clientWorkingSetBytes = before.WorkingSet64, + clientPrivateBytes = before.PrivateMemorySize64, + clientThreads = before.Threads.Count, + clientHandles = before.HandleCount, + collections = new[] { GC.CollectionCount(0) - gc[0], GC.CollectionCount(1) - gc[1], GC.CollectionCount(2) - gc[2] } + }); + } + } + sealed class Auth : IOpcUaUserAuthenticator + { + public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => Task.FromResult(username == "apikey" && password == Password ? OpcUaUserAuthResult.AllowKey("matrix", [OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck], "matrix") : OpcUaUserAuthResult.Deny("Invalid ephemeral fixture key")); + } + sealed class Gateway : IOpcUaNodeWriteGateway { public Task WriteAsync(string id, object? value, AddressSpaceRealm realm, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(true, null)); } + sealed class Source(string root) : IHistorianDataSource + { + void Log(string operation, int rows) => File.AppendAllText(Path.Combine(root, "history-requests.jsonl"), JsonSerializer.Serialize(new { operation, rows }) + "\n"); + public int MaxRawValuesPerRead => 7; + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) + { var all = Enumerable.Range(0, 25).Select(i => new DataValueSnapshot(i, 0, Epoch.AddSeconds(i), Epoch.AddSeconds(i))).Where(v => v.SourceTimestampUtc >= s && v.SourceTimestampUtc < e).ToList(); var rows = all.Take((int)(m == 0 ? 7 : m)).ToArray(); Log("raw", rows.Length); return Task.FromResult(new CoreHistory(rows, all.Count > rows.Length ? [1] : null)); } + public Task ReadEventsAsync(string? source, DateTime s, DateTime e, int max, CancellationToken ct) + { var all = Enumerable.Range(0, 25).Select(i => new HistoricalEvent($"e{i:00}", "matrix", Epoch.AddSeconds(i / 2), Epoch, "matrix-history", 500)).Where(v => v.EventTimeUtc >= s && v.EventTimeUtc < e).ToList(); var rows = all.Take(Math.Min(7, max <= 0 ? 7 : max)).ToArray(); Log("events", rows.Length); return Task.FromResult(new HistoricalEventsResult(rows, all.Count > rows.Length ? [1] : null)); } + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } +} diff --git a/scripts/validation/opcua-sdk-compatibility/README.md b/scripts/validation/opcua-sdk-compatibility/README.md new file mode 100644 index 000000000..6b3ea2eaa --- /dev/null +++ b/scripts/validation/opcua-sdk-compatibility/README.md @@ -0,0 +1,96 @@ +# OPC UA SDK process compatibility + +Run from a dedicated candidate checkout with .NET 10, Python 3, PowerShell 7, +and Docker. Supply the unchanged original application's existing `host/` and +`client/` publish directories. The output directory must not already exist. + +```powershell +./scripts/e2e/test-opcua-sdk-compatibility.ps1 ` + -BaselineRoot /absolute/original-checkout ` + -BaselineArtifacts /absolute/original-publishes ` + -CandidateRoot /absolute/dedicated-candidate-checkout ` + -OutputDirectory /absolute/fresh-evidence-directory +``` + +The baseline console references the exact published production assemblies; +it does not rebuild or edit the original application. The candidate console +references the production host, Client.Shared and OPC UA driver projects, and +the candidate CLI is separately published. Different SDK versions always run +in different processes. Source revisions, SDK assemblies, SHA-256 hashes, +commands, per-case results, actual namespace URIs, negotiated Shared security, +loaded client SDK assemblies, timestamps and backend request logs are retained. + +The required Shared legs are original→candidate, candidate→original and +candidate→candidate. A fourth original→original leg provides a reference for +both the application contract and the small performance sample. Every Shared +leg checks the exact scalar, array and condition browse identities, both +namespaces by URI, typed scalar/Int32[3] read/write, monitored Good quality and +source/server timestamps, the production alarm event filter and native +acknowledgment, and complete raw/event history across continuation points. +Event-history results include timestamp ties, exact IDs/order and positive and +negative `OfType` predicates. The historian is a deterministic fixture behind +the production server's session-bound continuation store; these results do +not certify any SQL Server, QuestDB or AVEVA backend. + +Server, Shared client and driver require trusted Basic256Sha256 +SignAndEncrypt peers, with certificate autoaccept disabled. Public certificates +are exchanged before sessions open. The test authenticator accepts one exact, +random, API-key-shaped credential from the process environment; production +ACL, write and condition-method checks use the resulting key identity. This +fixture does not replace cryptographic API-key-authenticator coverage. + +The driver and actual CLI run in a disposable `--network none` Linux container, +alongside a separate baseline or candidate server process. PKI lives only in +the mounted task directory; no host profile PKI or fleet endpoint is touched. +The driver hardcodes `LocalApplicationData/OtOpcUa/pki`, so Linux's task-local +`XDG_DATA_HOME` supplies isolation. The helper refuses a driver store outside +its task root. CLI uses its own task-local canonical store. CLI currently +forces `AutoAcceptCertificates=true` and exposes no strict-validation flag +(`CommandBase.CreateConnectionSettings`); the exchanged peers and encrypted +server remain in use, but CLI is not counted as strict client validation. +Driver history is N/A because the application removed that capability (#655); +Shared and CLI execute history reads instead. + +Three measured rounds follow ten warmup operations per kind in each round: +50 scalar reads, scalar writes, array reads and array writes per kind. The +sample records nearest-rank p50/p95/p99, sequential throughput, client CPU, +allocated bytes, GC collections, working set, private bytes, threads and +handles. This is a small local diagnostic on a shared development host. It +has no production fan-out, reconnect, queue, backpressure, backend or soak +performance claim and does not establish Gate G4. + +A separately published `server` helper can be used by other local consumer +checks. Supply a fresh directory and `OPCUA_MATRIX_PASSWORD` in the process +environment, then launch `dotnet ProcessHost.dll server `. +`ready.json`/`endpoint.txt` identify the ephemeral loopback port; touching +`/stop` shuts it down. Its default is strict SignAndEncrypt only. +`OPCUA_MATRIX_SERVER_SECURITY=none` is an explicit fixture-only override for +checking an existing unsecured consumer subset; the matrix runner clears that +override. Anonymous is disabled in either mode. Secure clients must place +their public certificates under `server-pki/trusted/certs` before server start +and trust the resulting `server-pki/own/certs` certificate. + +Explicitly supplied timestamps are asserted. Unset source timestamps are +recorded separately as observations, with their original application/SDK wire +values. The harness does not change historian algorithms or normalize the +unset-timestamp sentinel to manufacture parity. + +For the supplementary sustained warmup experiment, use the built consoles from +one completed matrix run: + +```sh +python3 scripts/validation/opcua-sdk-compatibility/performance.py \ + --baseline-harness /absolute/run/build-baseline/bin/Release/net10.0/ProcessHost.dll \ + --candidate-harness /absolute/run/build-candidate/bin/Release/net10.0/ProcessHost.dll \ + --output /absolute/fresh-performance-directory +``` + +This runs three fresh baseline/candidate process pairs serially, with 1,000 +warmup and 2,000 measured operations per kind in each process. Both server and +client receive `DOTNET_TieredCompilation=0` for this experiment only; the +application's shipped configuration is unchanged. The recorded budget is a +15% increase in the median of the three p95 samples for any operation kind. +Exit code 2 means a diagnostic budget regression, while process failures +remain errors. Even an experiment within that budget does not certify the +complete production performance matrix. The allocation-per-operation field +is the average across all four operation kinds in a measured workload. diff --git a/scripts/validation/opcua-sdk-compatibility/container-leg.sh b/scripts/validation/opcua-sdk-compatibility/container-leg.sh new file mode 100644 index 000000000..00a8b4fc5 --- /dev/null +++ b/scripts/validation/opcua-sdk-compatibility/container-leg.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Runs only inside the disposable, network-isolated task container. +set -euo pipefail +mkdir -p /task/driver/data /task/cli/data /task/server +XDG_DATA_HOME=/task/driver/data dotnet /candidate/ProcessHost.dll prepare-driver /task/driver > /task/driver-prepare.log 2>&1 +OPCUA_MATRIX_CLIENT_PKI=/task/cli/data/OtOpcUaClient/pki XDG_DATA_HOME=/task/cli/data dotnet /candidate/ProcessHost.dll prepare /task/cli > /task/cli-prepare.log 2>&1 +mkdir -p /task/server/server-pki/trusted/certs +cp /task/driver/data/OtOpcUa/pki/own/certs/* /task/server/server-pki/trusted/certs/ +cp /task/cli/data/OtOpcUaClient/pki/own/certs/* /task/server/server-pki/trusted/certs/ +dotnet /server/ProcessHost.dll server /task/server > /task/server.log 2>&1 & +server_pid=$! +cleanup() { + touch /task/server/stop + wait "$server_pid" || true +} +trap cleanup EXIT +for attempt in $(seq 1 600); do + test ! -f /task/server/ready.json || break + kill -0 "$server_pid" || exit 1 + sleep 0.1 +done +test -f /task/server/ready.json +endpoint=$(cat /task/server/endpoint.txt) +mkdir -p /task/driver/data/OtOpcUa/pki/trusted/certs /task/cli/data/OtOpcUaClient/pki/trusted/certs +cp /task/server/server-pki/own/certs/* /task/driver/data/OtOpcUa/pki/trusted/certs/ +cp /task/server/server-pki/own/certs/* /task/cli/data/OtOpcUaClient/pki/trusted/certs/ +result=0 +XDG_DATA_HOME=/task/driver/data dotnet /candidate/ProcessHost.dll driver /task/driver "$endpoint" > /task/driver.log 2>&1 || result=1 +# CLI currently autoaccepts by product design; it has no strict-validation flag. +# Its public certificates are nevertheless explicitly exchanged in this fixture. +XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll read --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev/Scalar' > /task/cli-read.log 2>&1 || result=1 +XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll historyread --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev/Scalar' --start 2026-10-01T00:00:00Z --end 2026-10-01T00:01:00Z --max 100 > /task/cli-raw-history.log 2>&1 || result=1 +XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll historyread --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev' --start 2026-10-01T00:00:00Z --end 2026-10-01T00:01:00Z --max 100 --events --fields EventId,Message > /task/cli-event-history.log 2>&1 || result=1 +exit "$result" diff --git a/scripts/validation/opcua-sdk-compatibility/performance.py b/scripts/validation/opcua-sdk-compatibility/performance.py new file mode 100644 index 000000000..0188d74d9 --- /dev/null +++ b/scripts/validation/opcua-sdk-compatibility/performance.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Supplementary warmed, matched native-process experiment; not full Gate G4.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import secrets +import shutil +import statistics +import subprocess +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--baseline-harness', type=Path, required=True) + parser.add_argument('--candidate-harness', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--tiered-compilation', choices=['0', '1'], default='0') + args = parser.parse_args() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32), + DOTNET_TieredCompilation=args.tiered_compilation, OPCUA_MATRIX_PERF_ROUNDS='1', + OPCUA_MATRIX_PERF_WARMUPS='1000', OPCUA_MATRIX_PERF_MEASURED='2000') + for key in ('OPCUA_MATRIX_SERVER_SECURITY', 'OPCUA_MATRIX_CLIENT_PKI'): + env.pop(key, None) + metadata = {'scope': __doc__, 'environment': {k: env[k] for k in ('DOTNET_TieredCompilation', 'OPCUA_MATRIX_PERF_ROUNDS', 'OPCUA_MATRIX_PERF_WARMUPS', 'OPCUA_MATRIX_PERF_MEASURED')}, + 'dotnetSdk': subprocess.check_output(['dotnet', '--version'], text=True).strip(), 'p95BudgetPercent': 15, + 'dateUtc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'logicalCpuCount': os.cpu_count()} + commands, results, hashes = [], [], [] + + def write(path, data): + path.write_text(json.dumps(data, indent=2) + '\n') + + def run(command, log): + commands.append({'argv': list(map(str, command)), 'log': str(log)}) + with log.open('w') as stream: + result = subprocess.run(list(map(str, command)), stdout=stream, stderr=subprocess.STDOUT, env=env, timeout=240) + if result.returncode: + raise RuntimeError(f'Process exited {result.returncode}; {log}') + + def copy_certs(source, destination): + destination.mkdir(parents=True, exist_ok=True) + files = list(source.glob('*')) + if not files: + raise RuntimeError('Missing public certificates: ' + str(source)) + for path in files: + shutil.copy2(path, destination / path.name) + + try: + for pair in range(1, 4): + for version, harness in [('baseline', args.baseline_harness.resolve()), ('candidate', args.candidate_harness.resolve())]: + leg = output / f'pair-{pair}-{version}' + leg.mkdir() + client, server = leg / 'client', leg / 'server' + client.mkdir() + server.mkdir() + run(['dotnet', harness, 'prepare', client], leg / 'prepare.log') + copy_certs(client / 'client-pki/own/certs', server / 'server-pki/trusted/certs') + server_log = (leg / 'server.log').open('w') + command = ['dotnet', str(harness), 'server', str(server)] + commands.append({'argv': command, 'log': str(leg / 'server.log')}) + process = subprocess.Popen(command, env=env, stdout=server_log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 60 + while not (server / 'ready.json').exists(): + if process.poll() is not None or time.monotonic() > deadline: + raise RuntimeError('Server failed to start: ' + str(leg / 'server.log')) + time.sleep(.1) + ready = json.loads((server / 'ready.json').read_text()) + copy_certs(server / 'server-pki/own/certs', client / 'client-pki/trusted/certs') + run(['dotnet', harness, 'performance', client, ready['endpoint']], leg / 'client.log') + result = json.loads((client / 'result.json').read_text()) + result.update({'pair': pair, 'version': version, 'server': ready}) + finally: + (server / 'stop').touch() + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=10) + server_log.close() + result['serverResources'] = json.loads((server / 'server-resources.json').read_text()) + results.append(result) + write(leg / 'measurement.json', result) + summary = [] + for operation in ('scalar-read', 'scalar-write', 'array-read', 'array-write'): + samples = {} + for version in ('baseline', 'candidate'): + rounds = [o for r in results if r['version'] == version for o in r['observations'] if 'benchmarkRound' in o] + samples[version] = {'p95Milliseconds': [r['latencyMilliseconds'][operation]['p95'] for r in rounds], + 'allocatedBytesPerOperation': [r['clientAllocatedBytesPerOperation'] for r in rounds], + 'clientWorkingSetBytes': [r['clientWorkingSetBytes'] for r in rounds], + 'serverWorkingSetBytes': [r['serverResources']['WorkingSet64'] for r in results if r['version'] == version]} + ratio = statistics.median(samples['candidate']['p95Milliseconds']) / statistics.median(samples['baseline']['p95Milliseconds']) + summary.append({'operation': operation, 'samples': samples, 'medianP95ChangePercent': 100 * (ratio - 1), 'status': 'diagnostic-regression' if ratio > 1.15 else 'diagnostic-within-budget'}) + metadata['summary'] = summary + metadata['status'] = 'diagnostic-regression' if any(r['status'] == 'diagnostic-regression' for r in summary) else 'diagnostic-within-budget' + for row in summary: + print(f"{row['operation']}: median p95 {row['medianP95ChangePercent']:+.1f}% ({row['status']})") + finally: + for harness in (args.baseline_harness.resolve(), args.candidate_harness.resolve()): + for path in [harness, *harness.parent.glob('Opc.Ua*.dll'), *harness.parent.glob('ZB.MOM.WW.OtOpcUa*.dll'), *harness.parent.glob('OtOpcUa.Host.dll')]: + hashes.append({'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'bytes': path.stat().st_size}) + write(output / 'artifact-hashes.json', hashes) + write(output / 'commands.json', commands) + write(output / 'measurements.json', results) + write(output / 'summary.json', metadata) + return 2 if metadata.get('status') == 'diagnostic-regression' else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/validation/opcua-sdk-compatibility/run.py b/scripts/validation/opcua-sdk-compatibility/run.py new file mode 100644 index 000000000..5a32f1acc --- /dev/null +++ b/scripts/validation/opcua-sdk-compatibility/run.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Separate-process production application compatibility gate (no mixed SDK process).""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import secrets +import shutil +import subprocess +import time +import uuid +import xml.etree.ElementTree as ET + +HERE = Path(__file__).resolve().parent +HOST = 'src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj' +CLIENT = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj' +DRIVER = 'src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj' +CLI = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj' + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + '\n') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--baseline-root', required=True, type=Path) + parser.add_argument('--baseline-artifacts', required=True, type=Path, help='Existing immutable host/ and client/ publishes') + parser.add_argument('--candidate-root', required=True, type=Path, help='Dedicated candidate checkout; this script builds it') + parser.add_argument('--container-image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble', help='Disposable local driver/CLI isolation; must supply .NET 10 and bash') + parser.add_argument('--output', required=True, type=Path, help='Fresh nonexistent evidence directory') + args = parser.parse_args() + args.baseline_root = args.baseline_root.resolve() + args.candidate_root = args.candidate_root.resolve() + args.baseline_artifacts = args.baseline_artifacts.resolve() + args.output = args.output.resolve() + args.output.mkdir(parents=True, exist_ok=False) + source_info = {} + for name, root in [('baseline', args.baseline_root), ('candidate', args.candidate_root)]: + source_info[name] = {'root': str(root), 'commit': subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip(), + 'sdkPackages': str(root / 'Directory.Packages.props')} + source_info['scope'] = 'Loopback application contract and 3 warmed 50-operation rounds per scalar/Int32[3] read/write kind; shared development host, no full load/reconnect/failover/backend/soak performance claim.' + source_info['dateUtc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + write_json(args.output / 'source-info.json', source_info) + commands = [] + matrix = [] + env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32)) + env.pop('OPCUA_MATRIX_CLIENT_PKI', None) + env.pop('OPCUA_MATRIX_SERVER_SECURITY', None) + + def run(command, log, process_env=env, timeout=300): + commands.append({'argv': ['' if x == env['OPCUA_MATRIX_PASSWORD'] else str(x) for x in command], 'log': str(log)}) + with log.open('w') as stream: + result = subprocess.run([str(x) for x in command], stdout=stream, stderr=subprocess.STDOUT, env=process_env, timeout=timeout) + return result.returncode + + def build(version): + folder = args.output / ('build-' + version) + folder.mkdir() + project = ET.Element('Project', Sdk='Microsoft.NET.Sdk') + props = ET.SubElement(project, 'PropertyGroup') + for k, v in {'OutputType': 'Exe', 'TargetFramework': 'net10.0', 'Nullable': 'enable', 'ImplicitUsings': 'enable', 'LangVersion': 'latest', 'DefineConstants': 'SDK2' if version == 'candidate' else ''}.items(): + ET.SubElement(props, k).text = v + items = ET.SubElement(project, 'ItemGroup') + ET.SubElement(items, 'FrameworkReference', Include='Microsoft.AspNetCore.App') + if version == 'candidate': + for path in (HOST, CLIENT, DRIVER): + ET.SubElement(items, 'ProjectReference', Include=str(args.candidate_root / path)) + else: + dlls = {} + for path in ('host', 'client'): + for dll in (args.baseline_artifacts / path).glob('*.dll'): + dlls[dll.name] = dll + for name, dll in sorted(dlls.items()): + ref = ET.SubElement(items, 'Reference', Include=name[:-4]) + ET.SubElement(ref, 'HintPath').text = str(dll) + ET.SubElement(ref, 'Private').text = 'true' + ET.SubElement(items, 'Compile', Include=str(HERE / 'ProcessHost.cs')) + csproj = folder / 'ProcessHost.csproj' + ET.ElementTree(project).write(csproj, encoding='unicode') + status = run(['dotnet', 'build', csproj, '-c', 'Release', '-m:1', '--nologo'], folder / 'build.log') + if status: + raise RuntimeError(f'{version} harness build failed ({status}); see {folder / "build.log"}') + return folder / 'bin/Release/net10.0/ProcessHost.dll' + + def certificates(source, target): + target.mkdir(parents=True, exist_ok=True) + files = list(source.glob('*')) + if not files: + raise RuntimeError('No public certificates at ' + str(source)) + for file in files: + shutil.copy2(file, target / file.name) + + def receipt(paths): + return [{'path': str(p), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'bytes': p.stat().st_size} for p in sorted(set(paths)) if p.is_file()] + + baseline = candidate = candidate_cli = None + try: + # Baseline builds only the portable harness against the exact published production DLLs. + baseline = build('baseline') + candidate = build('candidate') + candidate_cli = args.output / 'candidate-cli' + status = run(['dotnet', 'publish', args.candidate_root / CLI, '-c', 'Release', '-o', candidate_cli, '-m:1', '--nologo'], args.output / 'candidate-cli-build.log') + if status: + raise RuntimeError('Candidate CLI publish failed') + for name, client, server in [('baseline-client-to-baseline-server', baseline, baseline), ('baseline-client-to-candidate-server', baseline, candidate), ('candidate-client-to-baseline-server', candidate, baseline), ('candidate-client-to-candidate-server', candidate, candidate)]: + leg = args.output / name + leg.mkdir() + server_root = leg / 'server' + client_root = leg / 'shared' + server_root.mkdir() + client_root.mkdir() + status = run(['dotnet', client, 'prepare', client_root], leg / 'prepare.log') + if status: + matrix.append({'name': name, 'status': 'failed', 'stage': 'client-certificate-preparation'}) + continue + certificates(client_root / 'client-pki/own/certs', server_root / 'server-pki/trusted/certs') + server_log = (leg / 'server.log').open('w') + command = ['dotnet', str(server), 'server', str(server_root)] + commands.append({'argv': command, 'log': str(leg / 'server.log')}) + proc = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, env=env) + try: + deadline = time.monotonic() + 60 + while not (server_root / 'ready.json').exists(): + if proc.poll() is not None or time.monotonic() > deadline: + raise RuntimeError(f'{name}: server did not become ready; see server.log') + time.sleep(.1) + ready = json.loads((server_root / 'ready.json').read_text()) + certificates(server_root / 'server-pki/own/certs', client_root / 'client-pki/trusted/certs') + status = run(['dotnet', client, 'client', client_root, ready['endpoint']], leg / 'shared.log') + result = json.loads((client_root / 'result.json').read_text()) + history = [json.loads(x) for x in (server_root / 'history-requests.jsonl').read_text().splitlines()] if (server_root / 'history-requests.jsonl').exists() else [] + result.update({'name': name, 'server': ready, 'status': 'passed' if status == 0 else 'failed', 'historyBackendRequests': history, 'certificateValidation': 'explicit trusted peers; server and Shared client autoaccept disabled', 'authentication': 'ephemeral API-key-shaped credential, exact fixture authenticator; real authority/ACL/method gates'}) + write_json(leg / 'leg.json', result) + matrix.append(result) + except Exception as exc: + matrix.append({'name': name, 'status': 'failed', 'error': str(exc)}) + finally: + (server_root / 'stop').touch() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.terminate() + proc.wait(timeout=10) + server_log.close() + image_info = subprocess.check_output(['docker', 'image', 'inspect', args.container_image, '--format', '{{json .}}'], text=True) + image_data = json.loads(image_info) + write_json(args.output / 'container-image.json', {k: image_data.get(k) for k in ('Id', 'RepoDigests', 'Architecture', 'Os')}) + for server_name, server in [('baseline', baseline), ('candidate', candidate)]: + name = 'candidate-driver-cli-to-' + server_name + '-server' + leg = args.output / name + leg.mkdir() + shutil.copy2(HERE / 'container-leg.sh', leg / 'run.sh') + container_name = 'otopcua-sdk2-matrix-' + uuid.uuid4().hex[:12] + command = ['docker', 'run', '--rm', '--name', container_name, '--network', 'none', '-e', 'OPCUA_MATRIX_PASSWORD', + '-v', str(candidate.parent) + ':/candidate:ro', '-v', str(server.parent) + ':/server:ro', + '-v', str(candidate_cli) + ':/cli:ro', '-v', str(leg) + ':/task', args.container_image, 'bash', '/task/run.sh'] + try: + status = run(command, leg / 'container.log') + finally: + subprocess.run(['docker', 'rm', '-f', container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if (leg / 'driver/result.json').exists(): + result = json.loads((leg / 'driver/result.json').read_text()) + else: + result = {'cases': [], 'error': 'Driver result missing; inspect preparation/container/server logs'} + for operation, expected in [('read', 'Value: 92'), ('raw-history', '25 values returned.'), ('event-history', '25 events returned.')]: + log = leg / ('cli-' + operation + '.log') + passed = log.exists() and expected in log.read_text() + result['cases'].append({'name': 'production-CLI-' + operation, 'status': 'passed' if passed else 'failed', 'log': str(log), 'expectedOutput': expected}) + result.update({'name': name, 'status': 'passed' if status == 0 and all(x['status'] == 'passed' for x in result['cases']) else 'failed', + 'container': container_name, 'network': 'none; all peers in task container, separate SDK processes', + 'driverCertificateValidation': 'explicit trusted peers; autoaccept disabled', 'cliCertificateValidation': 'explicit exchanged peers; CLI always configures AutoAcceptCertificates=true (CommandBase.CreateConnectionSettings)', + 'server': json.loads((leg / 'server/ready.json').read_text()) if (leg / 'server/ready.json').exists() else None}) + write_json(leg / 'leg.json', result) + matrix.append(result) + + finally: + paths = [p for p in (baseline, candidate, args.baseline_root / 'Directory.Packages.props', args.candidate_root / 'Directory.Packages.props', HERE / 'ProcessHost.cs', HERE / 'run.py', HERE / 'container-leg.sh') if p is not None] + for folder in (args.baseline_artifacts / 'host', args.baseline_artifacts / 'client', candidate.parent if candidate else None, candidate_cli): + if folder is not None: + paths.extend(folder.glob('Opc.Ua*.dll')) + paths.extend(folder.glob('*.deps.json')) + paths.extend(folder.glob('ZB.MOM.WW.OtOpcUa*.dll')) + paths.extend(folder.glob('OtOpcUa.Host.dll')) + paths.extend(folder.glob('*.runtimeconfig.json')) + write_json(args.output / 'artifact-hashes.json', receipt(paths)) + write_json(args.output / 'commands.json', commands) + write_json(args.output / 'matrix.json', matrix) + return 1 if not matrix or any(x['status'] != 'passed' for x in matrix) else 0 + + +if __name__ == '__main__': + raise SystemExit(main())