OpcUaClient integration fixture — opc-plc in Docker closes the wire-level gap (#215). Closes task #215. The OpcUaClient driver had the richest capability matrix in the fleet (reads/writes/subscribe/alarms/history across 11 unit-test classes) + zero wire-level coverage; every test mocked the Session surface. opc-plc is Microsoft Industrial IoT's OPC UA PLC simulator — already containerized, already on MCR, pinned to 2.14.10 here. Wins vs the loopback-against-our-own-server option we'd originally scoped: (a) independent cert chain + user-token handling catches interop bugs loopback can't because both endpoints would share our own cert store; (b) pinned image tag fixes the test surface in a way our evolving server wouldn't; (c) the --alm flag opens the door to real IAlarmSource coverage later without building a custom FakeAlarmDriver. Loss vs loopback: both use the OPCFoundation.NetStandard stack internally so bugs common to that stack don't surface — addressed by a follow-up to add open62541/open62541 as a second independent-stack image (tracked). Docker is the fixture launcher — no PowerShell/Python wrapper like Modbus/pymodbus or S7/python-snap7 because opc-plc ships containerized. Docker/docker-compose.yml pins 2.14.10 + maps port 50000 + command flags --pn=50000 --ut --aa --alm; the healthcheck TCP-probes 50000 so docker ps surfaces ready state. Fixture OpcPlcFixture follows the same shape as Snap7ServerFixture + ModbusSimulatorFixture: collection-scoped, parses OPCUA_SIM_ENDPOINT (default opc.tcp://localhost:50000) into host + port, 2-second TCP probe at init, SkipReason records the failure for Assert.Skip. Forced IPv4 on the probe socket for the same reason those two fixtures do — .NET's dual-stack "localhost" resolves IPv6 ::1 first + hangs the full connect timeout when the target binds 0.0.0.0 (IPv4). OpcPlcProfile holds well-known node identifiers opc-plc exposes (ns=3;s=StepUp, FastUInt1, RandomSignedInt32, AlternatingBoolean) + builds OpcUaClientDriverOptions with SecurityPolicy.None + AutoAcceptCertificates=true since opc-plc regenerates its server cert on every container spin-up + there's no meaningful chain to validate against in CI. Three smoke tests covering what the unit suite couldn't reach: (1) Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack — full Secure Channel + Session + Read on ns=3;s=StepUp (counter that ticks every 1 s); (2) Client_reads_batch_of_varied_types_from_live_simulator — batch Read of UInt32 / Int32 / Boolean to prove typed Variant decoding, with an explicit ShouldBeOfType<bool> assertion on AlternatingBoolean to catch the common "variant gets stringified" regression; (3) Client_subscribe_receives_StepUp_data_changes_from_live_server — real MonitoredItem subscription on FastUInt1 (100 ms cadence) with a SemaphoreSlim gate + 3 s deadline on the first OnDataChange fire, tolerating container warm-up. Driver ran end-to-end against a live 2.14.10 container: all 3 pass; unit suite 78/78 unchanged. Container lifecycle verified (compose up → tests → compose down) clean, no leaked state. Docker/README.md documents install (Docker Desktop already on the dev box per Phase 1 decision #134), run (compose up / compose up -d / compose down), endpoint override (OPCUA_SIM_ENDPOINT), what opc-plc advertises with the current command flags, what's tunable via compose-file tweaks (--daa for username auth tests; --fn/--fr/--ft for subscription-stress nodes), known limitation that opc-plc shares the OPCFoundation stack with our driver. OpcUaClient-Test-Fixture.md updated — TL;DR flipped from "there is no integration fixture" to the new reality; "What it actually covers" gains an Integration section listing the three smoke tests. Follow-up the doc flags: add open62541/open62541 as a second image for fully-independent-stack interop coverage; once #219 (server-side IAlarmSource/IHistoryProvider integration tests) lands, re-run the client-side suite against opc-plc's --alm nodes to close the alarm gap from the client side too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-20 11:43:20 -04:00
parent 820567bc2a
commit c985c50a96
8 changed files with 454 additions and 12 deletions

View File

@@ -0,0 +1,103 @@
# opc-plc Docker fixture
[Microsoft Industrial IoT's opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc)
— pinned Docker image that stands up an OPC UA server at
`opc.tcp://localhost:50000` with step-up counters, random nodes, alarm
simulation, and other canonical simulated shapes. Replaces the PowerShell
launcher pattern used by the Modbus / S7 fixtures — Docker is the launcher
here since opc-plc ships pre-containerized.
| File | Purpose |
|---|---|
| [`docker-compose.yml`](docker-compose.yml) | Service definition for `otopcua-opc-plc` — image pin, port map, command flags. |
| (this file) | How to run it. |
## Install
Docker Desktop (Windows) or the docker CLI + daemon (Linux/macOS). Per
`CLAUDE.md` Phase 1 decision #134 the dev box already has Docker Desktop
configured with the WSL 2 backend — nothing new to install.
## Run
From the repo root:
```powershell
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests\Docker\docker-compose.yml up
```
Or from this folder:
```powershell
docker compose up
```
First run pulls the image (~250 MB). Startup takes ~5-10 seconds; the
healthcheck in the compose file surfaces ready state in `docker ps`.
To run detached (CI pattern):
```powershell
docker compose up -d
```
Stop with `docker compose down` (removes the container) or `docker compose stop`
(keeps it for fast restart).
## Endpoint
- Default: `opc.tcp://localhost:50000`
- Override by setting `OPCUA_SIM_ENDPOINT` before `dotnet test` — e.g. point
at a real OPC UA server in the lab, or at a different Docker host.
## What opc-plc advertises
Command flags in `docker-compose.yml` enable:
- `--pn=50000` — OPC UA endpoint on port 50000
- `--ut` — unsecured transport endpoint advertised (SecurityPolicy=None).
Secured policies are still on the endpoint list; `--ut` just adds an
unsecured option.
- `--aa` — auto-accept client certs (opc-plc's cert trust store lives
inside the container + resets each spin-up, so without this the driver's
first contact would be rejected).
- `--alm` — alarm simulation enabled; opc-plc publishes
`TripAlarmType`, `ExclusiveDeviationAlarmType`,
`NonExclusiveLevelAlarmType`, and `DialogConditionType` events.
Not turned on (but available via compose-file tweaks):
- `--daa` — disable anonymous auth; forces username or cert tokens. Flip
on when username-auth / cert-auth smoke tests land.
- `--fn` / `--fr` / `--ft` — fast-node variants (100 / 1 000 / 10 000 Hz
update rates) for subscription-stress coverage. Not needed for smoke.
- `--sn` / `--sr` — slow-node / special-shape coverage.
## Run the integration tests
In a separate shell, with the simulator running:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests
```
Tests auto-skip with a clear `SkipReason` when `localhost:50000` isn't
reachable within 2 seconds (`OpcPlcFixture`).
## Known limitations
opc-plc uses the OPCFoundation.NetStandard stack internally — same as
our driver. That means bugs common to the stack itself are **not** caught
by this fixture; the follow-up to add `open62541/open62541` as a second
independent-stack image (task tracked in #215's follow-ups) would close
that.
See [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)
for the full coverage map + what's still trusted from field deployments.
## References
- [opc-plc GitHub](https://github.com/Azure-Samples/iot-edge-opc-plc)
- [mcr.microsoft.com/iotedge/opc-plc tags](https://mcr.microsoft.com/v2/iotedge/opc-plc/tags/list)
- [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)