Files
lmxopcua/archreview/plans/INTEGRATION-SWEEP-STATUS.md
T
Joseph Doherty 859d63178a
v2-ci / build (pull_request) Successful in 4m23s
v2-ci / unit-tests (pull_request) Failing after 10m14s
fix(harness): make deploy E2E tests pass against real SQL (seed cluster FKs)
Integration-sweep follow-up #6 (SQL leg). The sweep flagged 7 heavy 2-node
deploy/failover/reconnect E2E tests as 'time out under amd64-emulated SQL' and
suggested raising deadlines. That diagnosis was WRONG: run against real
(native-amd64) SQL on the Docker host, they still hang — even at 4x deadline.
Root cause is that these tests had never actually run against real SQL, only the
EF in-memory provider, which does NOT enforce foreign keys.

Two distinct defects, both hidden by in-memory:

1. Missing FK-parent seed (5 tests). The deploy records a NodeDeploymentState row
   per node, FK'd to ClusterNode (FK_NodeDeploymentState_ClusterNode_NodeId). The
   tests never seeded ServerCluster + ClusterNode, so on SQL each node-state INSERT
   throws and the deploy never seals (waits the full deadline regardless of size).
   Added TwoNodeClusterHarness.SeedDefaultClusterAsync (seeds a ServerCluster +
   a ClusterNode per node; Warm/NodeCount=2 to satisfy the SQL CHECK constraint
   CK_ServerCluster_RedundancyMode_NodeCount AND the ClusterEnabledNodeCountMismatch
   validator). Called it in DeployHappyPath x2 / Failover / FleetDiagnostics /
   EquipmentNamespace; fixed DriverReconnect to seed node B + NodeCount=2.

2. Stale EquipmentId (EquipmentNamespace test; pre-existing on BOTH providers).
   Seeded EquipmentId='eq-1', but the later-added EquipmentIdNotDerived validator
   requires EquipmentId == DeriveEquipmentId(EquipmentUuid) ('EQ-'+first 12 hex).
   Fixed the seed to a canonical id + matching UUID. Only surfaced once the FK fix
   let the deploy reach equipment validation.

Also added OTOPCUA_HARNESS_SQL_HOST / OTOPCUA_HARNESS_LDAP_HOST overrides so the
harness fixtures can run on the native-amd64 Docker host (avoids arm64 mssql
emulation entirely).

Verified: the 5 SQL-backed classes go 11/11 green vs native SQL; in-memory default
unregressed (12/12). RoslynVirtualTagEvaluatorTests racing test is a pre-existing
flaky race (not SQL-backed; passes in isolation) — left as-is.
2026-07-15 06:43:15 -04:00

227 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Integration-test sweep — status + follow-ups (2026-07-14)
**Context:** ran the deferred `*.IntegrationTests` sweep (the round-2 STATUS.md "Full integration-suite
sweep" item that discharges R2-02 T18, R2-03 T11, R2-07 T5/T6, R2-08 T14, R2-11 T24).
**How:** tests run **on the Mac** (repo + .NET 10 SDK), pointing at **driver fixtures brought up on the
docker host `10.100.0.35`**, one driver at a time (serial — the suites have a ~16 GB/run memory leak).
Master at run time: `30672888` (branch `master`, clean).
**Docker host `10.100.0.35` (`DOCKER`) note:** has `dotnet 10.0.300`, 22 cores, but only ~7 GB free RAM
(the rest is shared SQL/GLAuth/fixtures) and **no repo checkout** — so the tests can't run *on* it (OOM risk
to shared infra). Run on the Mac against its fixtures. Fixture dirs live at `/opt/otopcua-<driver>/` (owned,
need sudo to create new ones — I deployed FOCAS to `~/otopcua-focas` on the host instead).
---
## Results
| Suite | Result | Root cause of failures |
|---|---|---|
| **OpcUaClient** | ✅ 4/4 | — (opc-plc `:50000`) |
| **OpcUaClient.Browser** | ✅ 1/1 | — |
| **S7** | ✅ 3/3 (2 skip) | skips = outage tests needing `S7_TIMEOUT_OUTAGE_*` env |
| **Modbus** (profile=standard) | ✅ 6/6 | — |
| **Host** | ⚠️ **105/117** (7F, 5 skip) | 7 heavy E2E time out under amd64-emulated SQL (OPEN); LDAP image swapped OpenLDAP→GLAuth 2026-07-15 (PR #451). The 5 skips are driver-fixture-gated E2E, not LDAP |
| **TwinCAT** | ⏭️ 13 skip | no TC3 XAR fixture exists (expected clean-skip) |
| **Modbus** (dl205 / mitsubishi / s7_1500 / exception_injection) | ✅ 16/13/10/17 (was 8F/5F/3F/8F) | **NOT drift** — fixture-cycling artifact, re-verified GREEN 2026-07-15 — see below |
| **AbCip** | ✅ 10P/3skip (was 6F/1P/3skip) | static-init-order bug FIXED 2026-07-15 (`326b22a7`) — see below |
| **FOCAS** | ✅ 10/10 (was 9F/1P) | topology + mock-timer-endianness FIXED 2026-07-15 — see below |
| **AbLegacy** | ✅ slc500 2/2, micrologix 1/1, plc5 1/1 (was 4F) | fixture-deployment gap FIXED 2026-07-15 — see below |
| **OpcUaServer** | ✅ 4/4 (was 4F) | cert-store gap + SDK read-throws drift FIXED 2026-07-15 — see below |
### Headline conclusion
**No OtOpcUa production regression.** Every failure is **pre-existing test/fixture infrastructure rot** in
long-deferred suites — or, in the Modbus case, a **fixture-cycling false alarm** (see below). The drivers are
fine: 4 suites fully green, **all Modbus profiles green** (standard + dl205/mitsubishi/s7_1500/exception once
correctly bound — the sweep's non-standard "failures" were the stale-container artifact), Host 105/117 green,
and all driver **unit** suites + the live `/run` gates pass. These integration suites were DEFERRED in round 2
and never run, so they'd bit-rotted in several distinct ways.
**Follow-up progress (2026-07-15): ALL 7 CLOSED.** #1 AbCip (PR #446), #5 OpcUaServer (PR #447), #2 Modbus
(PR #448 — false alarm), #4 AbLegacy (PR #449 — fixture gap), #3 FOCAS (PR #450 — topology + mock-timer), #7
(fixture deploys), #6 LDAP-image (PR #451 — OpenLDAP→GLAuth), and #6 SQL-deploy-E2E (PR #452 — misdiagnosed
"emulation timing"; really missing FK-parent seeds + a stale EquipmentId, hidden by the in-memory provider).
**Every driver + server integration suite is now verified GREEN. Zero OtOpcUa production regressions across the
entire sweep** — every failure was fixture/harness/test rot.
---
## Per-suite failure detail (evidence)
- **Modbus non-standard profiles** — ✅ **RE-VERIFIED GREEN 2026-07-15 (PR #448). The sweep's 8F/5F/3F/8F was a
false alarm — a fixture-cycling artifact, NOT pymodbus drift and NOT an OtOpcUa regression.** Correcting the
sweep's original diagnosis:
- The image genuinely runs **pymodbus 3.13.0** (`docker run --entrypoint python ... -c 'import pymodbus;
print(pymodbus.__version__)'` → `3.13.0`; the Dockerfile already pins `pymodbus[simulator]==3.13.0`). The
*"…removed in v4"* log line is 3.13.0's **forward-deprecation warning**, not evidence it runs 4.x — the
original sweep note misread it.
- Root cause: each Modbus profile is a **separate profile-gated compose service** sharing `:5020`. A plain
`docker compose down` (no `--profile`) does **not** stop the running profile container, so it keeps holding
`:5020`; the next `--profile … up` container silently fails to bind (compose still says "Started"). Every
later profile run then hit the **stale** container. Proven by a raw Modbus probe: the "mitsubishi" sim
served DL205's `0xCAFE` at HR0 (DL205's V0 marker) and Illegal-Data-Address for mitsubishi-only addresses,
while `docker ps` showed the mitsubishi container up with **no** port mapping and DL205 still holding `:5020`.
- After force-removing all modbus containers and bringing up each profile cleanly (verifying it actually
published `:5020`), all four pass: **dl205 16/16, mitsubishi 13/13, s7_1500 10/10, exception_injection
17/17** (the injector needs `--force-recreate` — it uses a different container name). **No code change** —
added a profile-cycling gotcha + reliable-cycle recipe to `Docker/README.md`.
- **AbCip** — **static-init-order bug in the test harness** (always-fail, blocks the whole suite).
`tests/.../AbCip.IntegrationTests/AbServerProfile.cs`: `KnownProfiles.All { get; } = [ControlLogix, …]` is
declared at **line 30**, *before* the `static readonly` profile fields (line 34+). C# runs static
initializers in textual order → `All` captures `[null,null,null,null]` → `ForFamily` NREs at `p.Family`
(line 61) and `AbServerFixture..ctor` gets null. **Fix (trivial, safe):** make `All` an expression-bodied
property: `public static IReadOnlyList<AbServerProfile> All => [ControlLogix, CompactLogix, Micro800,
GuardLogix];` (evaluated on access, after the fields are set). Unblocks the suite; then re-run to see if the
actual sim tests pass.
- **FOCAS** — ✅ **FIXED 2026-07-15 (PR #450). Two test/fixture bugs, no OtOpcUa driver change.**
1. *Topology:* `FocasSimFixture` already reads an env-overridable endpoint (`OTOPCUA_FOCAS_SIM_ENDPOINT`,
default `localhost:8193`) and exposes `Host`/`Port`, but `WireBackendTests` + `WireBackendCoverageTests`
**hardcoded** `focas://127.0.0.1:8193`. Against a remote fixture the skip-gate passed (remote reachable) but
the driver dialed localhost → KeyNotFound. Fix: added a `DeviceUri` property to the fixture and derived each
test's `DeviceHost` from it — recovered **8 of 9**.
2. *Mock timer endianness (newly surfaced):* the last failure (`Timers_populate`) read `60397977600` for a
`3600 s` power-on timer (= `0x3C000000 × 60`). Root cause: the focas-mock's `_wire_timer` encoded the
`minute`/`msec` fields **big-endian** (`_u32`), but `cnc_rdtimer`'s timer fields are **little-endian** on
real hardware — a documented, live-31i-B-validated quirk (`FocasWireClient.ParseTimer` decodes them LE, per
`docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`). **The driver was correct; the mock was wrong.** Fix:
`_wire_timer` now emits little-endian (`_u32_le`) → suite **10/10**. Fixture deployed at `~/otopcua-focas`.
- **AbLegacy** — ✅ **FIXED 2026-07-15 (PR #449). Fixture-deployment gap, not a code defect.** The AbLegacy suite
needs `ab_server` in **PCCC mode** (`--plc=SLC500|Micrologix|PLC/5`), which reuses the **AbCip image**
(`otopcua-ab-server:libplctag-release`, already on the host) and binds `:44818`. Two issues: (a) no PCCC
container was deployed (`~/otopcua-ablegacy` didn't exist); (b) the sweep's *4F-not-skip* means `:44818` was
reachable but answering **CIP** (a leftover AbCip container) — the PCCC reads returned Bad. Same shared-port
contamination class as the Modbus false alarm. Deployed the compose to `~/otopcua-ablegacy` on the host (image
already present, no build needed) and ran each family with `AB_LEGACY_COMPOSE_PROFILE` set to match the running
container (**required** — without it the `[Theory]` parametrises over all 3 families and 2 fail against a
single-family container): **slc500 2/2, micrologix 1/1 (+1 skip), plc5 1/1 (+1 skip)** — the skips are
family-inapplicable file types (e.g. no `F8` float file seeded on micrologix/plc5). **No code change.**
- **OpcUaServer** — ✅ **FIXED 2026-07-15** (PR #447). Was 4F on `Opc.Ua.ServiceResultException :
TrustedIssuerCertificates StorePath must be specified` — the in-test *client* `SecurityConfiguration`
omitted the trusted-issuer/peer/rejected store paths that `ValidateAsync` requires. Fix: `TestClientSecurity`
helper rooting Directory stores at a throwaway PKI dir (mirrors `DefaultApplicationConfigurationFactory`).
Fixing it unmasked a second drift — the 1.5.378 node-cache read path throws
`ServiceResultException(BadNodeIdUnknown)` for a removed node rather than returning a bad `DataValue`;
the two subscription-survival assertions now use `Should.ThrowAsync`. Suite **4F → 4/4 green**.
- **Host** — largely healthy (105/117). Two independent issues:
- **LDAP image — ✅ FIXED 2026-07-15 (PR #451).** The opt-in real-LDAP mode (`OTOPCUA_HARNESS_USE_LDAP=1`)
used `bitnami/openldap:2.6`, gone since Bitnami deprecated their free catalog in 2025. **Replaced it with
GLAuth** — the same LDAP server the rest of the project uses (docker-dev + the live OPC UA data-plane auth,
both against the shared GLAuth on `:3893`), removing the lone OpenLDAP outlier. Added a harness
`glauth/config.toml` (base DN `dc=zb,dc=local`, search-capable `serviceaccount`, dev users alice=full /
bob=read-only, role groups with the same gidnumbers as `scadaproj/infra/glauth`) + repointed the harness's
service-account override to `cn=serviceaccount`. Live-verified all three bind legs against a deployed
container on `:3894` (serviceaccount search returns alice + 5 memberOf groups; alice/bob bind OK; wrong
password → `Invalid credentials (49)`). NB: the real-LDAP mode is **opt-in** — the default Host run uses
`StubLdapAuthService`, so this never blocked the default suite (the sweep's "5 LDAP skips" were actually the
driver-fixture-gated E2E tests, not LDAP).
- **SQL-backed deploy E2E — ✅ FIXED 2026-07-15 (PR #452). The sweep's "amd64-emulated-SQL timing" diagnosis
was WRONG.** Running the 7 heavy E2E against real (native-amd64) SQL on the Docker host proved they don't
time out for speed — they had **never actually run against real SQL**, only the EF in-memory provider (which
ignores FK constraints). Two distinct root causes, both hidden by in-memory:
- *Missing FK-parent seed (5 tests):* the deploy writes a `NodeDeploymentState` row per node, which FKs to
`ClusterNode` (`FK_NodeDeploymentState_ClusterNode_NodeId`). The tests never seeded `ServerCluster` +
`ClusterNode` rows, so on SQL each node-state INSERT throws → the deploy never seals (waits the full
deadline regardless of how high — proven at 4× it still hung). Added `TwoNodeClusterHarness.SeedDefaultClusterAsync`
(seeds a `ServerCluster` + a `ClusterNode` per node; `Warm`/NodeCount=2 to satisfy both the SQL CHECK
constraint `CK_ServerCluster_RedundancyMode_NodeCount` and the `ClusterEnabledNodeCountMismatch` validator)
and called it in `DeployHappyPathTests`×2 / `FailoverDuringDeployTests` / `FleetDiagnosticsRoundTripTests` /
`EquipmentNamespaceMaterializationTests`; fixed `DriverReconnectE2eTests` to seed node B + declare NodeCount=2.
- *Stale `EquipmentId` (1 test, pre-existing on BOTH providers):* `EquipmentNamespaceMaterializationTests`
seeded `EquipmentId="eq-1"`, but the `EquipmentIdNotDerived` validator (added later) requires
`EquipmentId == DeriveEquipmentId(EquipmentUuid)` = `EQ-` + first 12 hex of the UUID. Fixed the seed to a
canonical id + matching UUID. (Was failing on in-memory too; only surfaced once the FK fix let the deploy
reach equipment validation.)
- Added `OTOPCUA_HARNESS_SQL_HOST` / `OTOPCUA_HARNESS_LDAP_HOST` env overrides so the fixtures can live on the
native-amd64 Docker host (`docker compose up -d sql` there; run with `OTOPCUA_HARNESS_SQL_HOST=10.100.0.35,14331`).
- Result: the 5 SQL-backed classes go **11/11 green vs native SQL**, and the in-memory default is unregressed
(12/12). `RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts…` is a **pre-existing flaky
race test** (not SQL-backed; passes in isolation, flaked once under heavy parallel load) — left as-is.
- **TwinCAT** — 13 skip cleanly (no TC3 XAR fixture exists; unit-only per the driver's design). Expected.
---
## Follow-up backlog (distinct, not one fix)
1. ~~**AbCip static-init bug** — trivial always-fail test bug. Expression-body `KnownProfiles.All`.~~
✅ **DONE 2026-07-15** (commit `326b22a7`, PR #446). Live-verified against the controllogix fixture:
suite went **6F/1P/3skip → 10 pass / 0 fail / 3 skip** (skips = emulator-mode tests needing a different
fixture mode).
2. ~~**Modbus non-standard profiles** — pin pymodbus in the fixture Dockerfile.~~
✅ **RESOLVED 2026-07-15 (PR #448) — false alarm, no code change.** Image already runs the pinned
pymodbus 3.13.0; the 8F/5F/3F/8F was a fixture-cycling artifact (stale profile container holding `:5020`).
Re-verified all four profiles GREEN (16/13/10/17). Added a profile-cycling gotcha + reliable-cycle recipe
to `Docker/README.md`.
3. ~~**FOCAS** — reconcile the 127.0.0.1-mock topology with the remote-fixture model.~~
✅ **DONE 2026-07-15 (PR #450).** Tests now derive `DeviceHost` from the fixture endpoint (recovered 8/9);
also fixed a newly-surfaced mock bug — `_wire_timer` encoded the timer big-endian but real hardware +
driver use little-endian. Suite **10/10**. No OtOpcUa driver change.
4. ~~**AbLegacy** — deploy an AbLegacy sim fixture (none on host).~~
✅ **DONE 2026-07-15 (PR #449) — fixture-deployment gap, no code defect.** Deployed the PCCC compose to
`~/otopcua-ablegacy` (reuses the AbCip image, already on host). All 3 families GREEN (slc500 2/2, micrologix
1/1, plc5 1/1). Recipe added below; `AB_LEGACY_COMPOSE_PROFILE` MUST match the running container.
5. ~~**OpcUaServer** — supply cert-store StorePath in the harness config.~~
✅ **DONE 2026-07-15** (PR #447). Added `TestClientSecurity` helper building a validated client
`SecurityConfiguration` with Directory own/issuer/trusted/rejected stores under a throwaway PKI dir
(mirrors `DefaultApplicationConfigurationFactory`); wired into both `DualEndpointTests` +
`SubscriptionSurvivalTests`. That unmasked a **second** drift: the 1.5.378 node-cache read path
*throws* `ServiceResultException(BadNodeIdUnknown)` for a removed node instead of returning a bad
`DataValue` — updated the two subscription-survival assertions to `Should.ThrowAsync`. Suite **4F → 4/4**.
6. **Host** — ⬐ split into two:
- ~~replace `bitnami/openldap:2.6` (image gone)~~ ✅ **DONE 2026-07-15 (PR #451)** — swapped the harness LDAP
to **GLAuth** (unifies on the LDAP the rest of the project uses); live-verified the binds on `:3894`.
- ~~**amd64-emulated-SQL timing**~~ ✅ **DONE 2026-07-15 (PR #452) — misdiagnosed.** Not a timing/emulation
issue: the deploy E2E tests had never run against real SQL (in-memory ignores FKs) and lacked the
`ServerCluster`/`ClusterNode` seed the `NodeDeploymentState` FK requires → deploy never seals. Added a
harness seeding helper + `OTOPCUA_HARNESS_SQL_HOST` override; also fixed a stale `EquipmentId` in one test.
11/11 green vs native SQL, in-memory unregressed. (Roslyn racing test = pre-existing flaky, left as-is.)
7. ~~**Fixture deployment gap** — FOCAS + AbLegacy fixtures aren't on the host.~~
✅ **RESOLVED 2026-07-15.** Both now deployed under the home dir: `~/otopcua-ablegacy` (PR #449) +
`~/otopcua-focas` (PR #450). (`/opt` still needs sudo; the home-dir deploys work fine.)
---
## Fixture setup recipes (to re-run the sweep)
Bring up on the docker host (distinct ports, can coexist except Modbus profiles share `:5020`):
```bash
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-modbus && docker compose --profile standard up -d' # :5020
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d' # :44818
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-s7 && docker compose --profile s7_1500 up -d' # :1102
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient && docker compose up -d' # :50000
ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d' # :44818 (PCCC; reuses AbCip image)
ssh dohertj2@10.100.0.35 'cd ~/otopcua-focas && docker compose up -d --build' # :8193
```
Run per suite (env → project):
| Suite | Env | Project |
|---|---|---|
| OpcUaClient(+Browser) | `OPCUA_SIM_ENDPOINT=opc.tcp://10.100.0.35:50000` | `tests/Drivers/…OpcUaClient(.Browser).IntegrationTests` |
| S7 | `S7_SIM_ENDPOINT=10.100.0.35:1102` | `tests/Drivers/…S7.IntegrationTests` |
| Modbus | `MODBUS_SIM_ENDPOINT=10.100.0.35:5020` `MODBUS_SIM_PROFILE=<standard\|dl205\|mitsubishi\|s7_1500\|exception_injection>` (swap the host sim to match) | `tests/Drivers/…Modbus.IntegrationTests` |
| AbCip | `AB_SERVER_ENDPOINT=10.100.0.35:44818` `AB_SERVER_PROFILE=controllogix` | `tests/Drivers/…AbCip.IntegrationTests` |
| AbLegacy | `AB_LEGACY_ENDPOINT=10.100.0.35:44818` `AB_LEGACY_COMPOSE_PROFILE=<slc500\|micrologix\|plc5>` (**must** match the running PCCC container — else the `[Theory]` runs all 3 families and 2 fail) | `tests/Drivers/…AbLegacy.IntegrationTests` |
| FOCAS | `OTOPCUA_FOCAS_SIM_ENDPOINT=10.100.0.35:8193` (tests now honour this — no longer hardcoded to 127.0.0.1) | `tests/Drivers/…FOCAS.IntegrationTests` |
| Host | `OTOPCUA_HARNESS_USE_SQL=1` (+ SQL `:14331` via the harness `docker-compose.yml up sql`) — set `OTOPCUA_HARNESS_USE_LDAP=1` to exercise real binds against the **GLAuth** service (`up ldap`, host `:3894`; users alice/bob, svc `cn=serviceaccount`/`serviceaccount123`) | `tests/Server/…Host.IntegrationTests` |
**Modbus profile cycling:** the sim services share `:5020`, so to run a profile:
`ssh … 'cd /opt/otopcua-modbus && docker compose down && docker compose --profile <p> up -d'`, then run with
`MODBUS_SIM_PROFILE=<p>` (each class skips unless its profile is active). Wait for `:5020` before running —
TCP opens before the register map fully loads.
---
## Current state (post-sweep)
- **All fixtures torn down** — modbus/abcip/s7/opcuaclient/focas on the host removed (ports 5020/44818/1102/
50000/8193 all closed); the local Host SQL fixture `down -v`'d. Shared host is clean.
- **Repo:** `master @ 30672888`, clean. No sweep changes committed (this file is the only artifact).
- **Nothing merged for the sweep** — it's a read-only test run; the follow-ups above are unstarted.