Compare commits
244 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a67ebc8a8 | |||
| ad7f9e731f | |||
| a5d857d5b2 | |||
| a79ed5fff1 | |||
| aaf869145a | |||
| 08cddfe128 | |||
| df0dc516c3 | |||
| febe462750 | |||
| c18943f6e1 | |||
| 0b8cad1870 | |||
| b1b3f3ff23 | |||
| c1ce5833e9 | |||
| 83b8d75112 | |||
| 3e9793eff7 | |||
| 3e4450e0b1 | |||
| 3be4e97b89 | |||
| 1d7028c2f9 | |||
| fae960c157 | |||
| c3ae458a95 | |||
| fcf0963f1c | |||
| b599d81f86 | |||
| 826b65c6a1 | |||
| 258468f94b | |||
| e17292dc7f | |||
| 523d79cab0 | |||
| 10057cfa40 | |||
| 7869494393 | |||
| 6667b233b6 | |||
| f9c7d6a577 | |||
| 0ec9ec29ef | |||
| 72de494b9f | |||
| de6a8974c1 | |||
| 84e225e0ad | |||
| b9bdfee189 | |||
| 1b6dedc142 | |||
| da074adce9 | |||
| 60d2fdf25c | |||
| a71d33be35 | |||
| f21883d607 | |||
| 974d835d08 | |||
| 534982948a | |||
| 200fd6b4c4 | |||
| 897b06016c | |||
| 2eb3ceb961 | |||
| d686e12123 | |||
| 497d8be1d5 | |||
| 33d40901d2 | |||
| 26833073ca | |||
| c843abf8b1 | |||
| 6fa29c6c9a | |||
| d731ed98fa | |||
| 33912694fd | |||
| 0a679f2c2a | |||
| f5552c23d4 | |||
| 318e432d93 | |||
| a32ba1f5c5 | |||
| 9071a3aae0 | |||
| 2c1dc8bb14 | |||
| 2c938ea6f7 | |||
| 4cef8124fe | |||
| 4893f7288d | |||
| 47acdde78d | |||
| c6d9b20d9f | |||
| 11de14d12e | |||
| aadbf49678 | |||
| 70d764b063 | |||
| 11bcff6af5 | |||
| de41963587 | |||
| a78b212c95 | |||
| 075c0e69da | |||
| b7f5e887ee | |||
| 933dd1a874 | |||
| c1619d95f5 | |||
| 8ba289f975 | |||
| d0777eee29 | |||
| 83856b7c27 | |||
| c4f315ec90 | |||
| 257caa7bd1 | |||
| 6534875476 | |||
| d2d7730830 | |||
| 2844180865 | |||
| d3ab2bfbaf | |||
| 88e773af36 | |||
| f35ebd7aaf | |||
| 0cbb82e466 | |||
| 7b6884031d | |||
| 7ff7a60ae0 | |||
| 8faa2bf23d | |||
| 2099713ed8 | |||
| c05ffc7b39 | |||
| 60017177cb | |||
| 26bae36f8b | |||
| 368390ea9d | |||
| 8f950722c6 | |||
| 1d729fb0f8 | |||
| 0b99aceacb | |||
| d57b42bcd6 | |||
| 5e87f7e16f | |||
| 695fa6408b | |||
| 61193629b6 | |||
| e3a27422a1 | |||
| 32d7fd7cc9 | |||
| de666b24c3 | |||
| a4fb97aef8 | |||
| da4634d67e | |||
| 869be660fd | |||
| a8916c3e08 | |||
| 79b2345834 | |||
| 4df5b849ac | |||
| a58151e99e | |||
| 1fd093d95d | |||
| f210f09caf | |||
| 042f3b6a65 | |||
| bc40388914 | |||
| b719194046 | |||
| 7570df76d3 | |||
| 244949caa3 | |||
| a5a0d06dbe | |||
| 6882761f4c | |||
| 15f3797f1e | |||
| 534d670b21 | |||
| b351a81c8f | |||
| f655efc570 | |||
| c4116e54c9 | |||
| c3fec1426c | |||
| a2761e4b98 | |||
| 4a469fbe06 | |||
| e2fa6754bb | |||
| b76561a780 | |||
| c49fccbe0c | |||
| 5622e51006 | |||
| 9e479ce675 | |||
| af691f3291 | |||
| 453340e71e | |||
| b64d670303 | |||
| c83e9397e6 | |||
| 74b9218a92 | |||
| 532e9933f3 | |||
| ee8add4416 | |||
| bc4fce5fbe | |||
| 7a0b8525a9 | |||
| 560b327ee1 | |||
| d1b6cff085 | |||
| ef17d2e595 | |||
| e439100937 | |||
| 7c9621040e | |||
| 1b0baf7025 | |||
| f31af0093f | |||
| 6e365ef1a9 | |||
| 1dbd3b2a6d | |||
| 48c3c56073 | |||
| 5475ab2aa3 | |||
| 1a143beeb9 | |||
| 641b2ecbcf | |||
| 09d1bbac00 | |||
| b869af2b3d | |||
| 56be42913c | |||
| dc8a2dd52c | |||
| d605d0b20d | |||
| 85676db3a5 | |||
| bec2988309 | |||
| 7cd5cde315 | |||
| 7c92297d0e | |||
| 81f09a7054 | |||
| c962b86bde | |||
| fcd0b9b355 | |||
| 0d3ec46c14 | |||
| 662f3f9f5c | |||
| dcd2509548 | |||
| 64e4726fff | |||
| 494da22cd1 | |||
| 063005fefa | |||
| ffcc8d1065 | |||
| 4b374fd177 | |||
| 54f0dbddb9 | |||
| c19d124e89 | |||
| f3f328c25c | |||
| 4584612a1a | |||
| 4203b84d51 | |||
| 29370fde3c | |||
| 3f23a1acd3 | |||
| 4d5c6ac892 | |||
| c4086c243c | |||
| a971db3ee5 | |||
| 5f8fa7004c | |||
| 059a6218f7 | |||
| 8149739161 | |||
| 2c16062457 | |||
| dc21cbad53 | |||
| dfbf6793de | |||
| a243cfd126 | |||
| 5cad9b260e | |||
| a3073d16bf | |||
| efcc2311e6 | |||
| 7014c9376c | |||
| 27b3a014da | |||
| 55e8bf70d9 | |||
| c0ce5d02bd | |||
| a28f4cdd25 | |||
| a008530af6 | |||
| 1ff3875a19 | |||
| 85af126406 | |||
| f2f6eeb74e | |||
| 8c0a32025d | |||
| 5ffbc42d8c | |||
| 5f0e0482ed | |||
| d892ab9e12 | |||
| 9f62f2c242 | |||
| a88721ce31 | |||
| 4902295211 | |||
| b474d63335 | |||
| 5058a56645 | |||
| dc12c3732e | |||
| c1c68c9134 | |||
| af06648558 | |||
| 64e3fbe035 | |||
| f9fc7dd2e1 | |||
| 7dfbca6469 | |||
| 44b8a9c7ff | |||
| 60beb9128e | |||
| 6884de9774 | |||
| c064ec16cf | |||
| ed1c17bc7b | |||
| 1e64488c0d | |||
| f02071c9a2 | |||
| 993e012e55 | |||
| 961e09430a | |||
| a1a7646b33 | |||
| e4d0d82f7f | |||
| 2915755a7c | |||
| a5c6ce279e | |||
| 59b3d9f295 | |||
| 89095c15e3 | |||
| bdae749b2b | |||
| e8c4f18607 | |||
| cb936db7d6 | |||
| a5412c16a3 | |||
| dce2528c68 | |||
| 83eda9e826 | |||
| 70ffd2849d | |||
| 898a47746d | |||
| 25ce111981 | |||
| 7209bc99e2 | |||
| 2c49f18442 |
@@ -61,10 +61,16 @@ jobs:
|
||||
integration:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project:
|
||||
- tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests
|
||||
- tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- name: dotnet test Host.IntegrationTests
|
||||
run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --configuration Release --filter "Category!=E2E"
|
||||
- name: dotnet test ${{ matrix.project }}
|
||||
run: dotnet test ${{ matrix.project }} --configuration Release --filter "Category!=E2E"
|
||||
|
||||
+11
@@ -21,6 +21,8 @@ desktop.ini
|
||||
# NuGet
|
||||
packages/
|
||||
*.nupkg
|
||||
# … but DO track repo-local feed for mxaccessgw client (not yet on public nuget.org).
|
||||
!nuget-packages/*.nupkg
|
||||
|
||||
# Certificates
|
||||
*.pfx
|
||||
@@ -40,3 +42,12 @@ config_cache*.db
|
||||
|
||||
# Client CLI/UI runtime scratch (last-connected endpoint cache)
|
||||
session.dat
|
||||
|
||||
# Secrets / local credentials — never commit
|
||||
sql_login.txt
|
||||
|
||||
# OPC UA certificate store (runtime PKI: own/trusted/issued/rejected certs + keys)
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/
|
||||
|
||||
# Documentation audit scratch dir (untracked worktree)
|
||||
.docs-audit/
|
||||
|
||||
@@ -119,7 +119,7 @@ See `docs/v2/dev-environment.md` for the full inventory and rationale.
|
||||
|
||||
## Transport Security
|
||||
|
||||
The server supports configurable OPC UA transport security via the `Security` section in `appsettings.json`. Phase 1 profiles: `None` (default), `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Security profiles are resolved by `SecurityProfileResolver` at startup. The server certificate is always created even for `None`-only deployments because `UserName` token encryption depends on it. See `docs/security.md` for the full guide.
|
||||
The server supports configurable OPC UA transport security via the `OpcUa:EnabledSecurityProfiles` list in `appsettings.json`. Phase 1 profiles (the `OpcUaSecurityProfile` enum members): `None` (default), `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`. Security policies are built from the enabled profiles by `BuildSecurityPolicies` at startup. The server certificate is always created even for `None`-only deployments because `UserName` token encryption depends on it. See `docs/security.md` for the full guide.
|
||||
|
||||
## Redundancy
|
||||
|
||||
@@ -127,13 +127,15 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) implements `IUserAuthenticator`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
|
||||
The server uses LDAP-based user authentication via the `Security:Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server, and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapOpcUaUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`) implements `IOpcUaUserAuthenticator`, delegating the LDAP bind + group lookup to `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`, an `ILdapAuthService`). See `docs/security.md` for the full guide.
|
||||
|
||||
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` uses a separate ephemeral bitnami/openldap on port 3894 for automated tests — that is distinct from the shared dev GLAuth.)
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- **Logging**: Serilog with rolling daily file sink
|
||||
- **Unit tests**: xUnit + Shouldly for assertions
|
||||
- **Service hosting (Server, Admin)**: .NET generic host with `AddWindowsService` (decision #30 — replaced TopShelf in v2; see `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs`)
|
||||
- **Service hosting (Server, Admin)**: .NET generic host with `AddWindowsService` (decision #30 — replaced TopShelf in v2; see `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs`)
|
||||
- **OPC UA**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server`
|
||||
|
||||
## OPC UA .NET Standard Documentation
|
||||
@@ -150,3 +152,5 @@ dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tc
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode"
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500
|
||||
```
|
||||
|
||||
Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`.
|
||||
|
||||
+20
-10
@@ -1,9 +1,7 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Akka" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Cluster" Version="1.5.62" />
|
||||
@@ -35,7 +33,6 @@
|
||||
<PackageVersion Include="libplctag" Version="1.5.2" />
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
<PackageVersion Include="MessagePack" Version="2.5.187" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||
@@ -73,19 +70,20 @@
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.3-beta.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageVersion Include="Polly.Core" Version="8.6.6" />
|
||||
<PackageVersion Include="S7netplus" Version="0.20.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
@@ -98,6 +96,18 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
<PackageVersion Include="xunit.v3" Version="1.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
<add key="local-mxgw" value="./nuget-packages" />
|
||||
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
<packageSource key="local-mxgw">
|
||||
<package pattern="ZB.MOM.WW.MxGateway.*" />
|
||||
</packageSource>
|
||||
<packageSource key="dohertj2-gitea">
|
||||
<package pattern="ZB.MOM.WW.Health" />
|
||||
<package pattern="ZB.MOM.WW.Health.*" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||
<package pattern="ZB.MOM.WW.Configuration" />
|
||||
<package pattern="ZB.MOM.WW.Auth" />
|
||||
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||
<package pattern="ZB.MOM.WW.Audit" />
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
@@ -41,10 +41,10 @@ dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||
dotnet test ZB.MOM.WW.OtOpcUa.slnx
|
||||
|
||||
# Run the server in dev (foreground)
|
||||
dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server
|
||||
dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host
|
||||
```
|
||||
|
||||
The server starts on `opc.tcp://localhost:4840` with the `None` security profile. Configure `Security.Profiles` in `src/Server/ZB.MOM.WW.OtOpcUa.Server/appsettings.json` to enable `Basic256Sha256-Sign` or `Basic256Sha256-SignAndEncrypt`. See [docs/security.md](docs/security.md).
|
||||
The server starts on `opc.tcp://localhost:4840` with the `None` security profile. Configure `Security.Profiles` in `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` to enable `Basic256Sha256-Sign` or `Basic256Sha256-SignAndEncrypt`. See [docs/security.md](docs/security.md).
|
||||
|
||||
## Install as Windows Services
|
||||
|
||||
|
||||
@@ -21,16 +21,27 @@
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/">
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/Driver CLIs/">
|
||||
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
|
||||
@@ -61,14 +72,17 @@
|
||||
<Project Path="tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Server/">
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/">
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
|
||||
@@ -86,6 +100,8 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/Driver CLIs/">
|
||||
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj" />
|
||||
|
||||
+73
-23
@@ -1,20 +1,70 @@
|
||||
# docker-dev
|
||||
|
||||
Mac-friendly four-node OtOpcUa fleet for manual UI exercise + integration smoke tests. Spins up an Akka cluster + SQL Server + OpenLDAP + Traefik in front of two admin nodes.
|
||||
Mac-friendly multi-cluster OtOpcUa fleet for manual UI exercise + integration smoke tests. Spins up **three isolated Akka clusters** + SQL Server + Traefik on the same Compose network. All three clusters share the single `OtOpcUa` ConfigDb — multi-tenancy is enforced by per-row `ServerCluster.ClusterId` scoping. Akka.Cluster gossip stays isolated between meshes because their seed-node lists are disjoint, even though they share the same system name `otopcua`.
|
||||
|
||||
## Stack
|
||||
|
||||
### Shared infrastructure
|
||||
|
||||
| Service | Role | Ports |
|
||||
|---|---|---|
|
||||
| `sql` | SQL Server 2022 (`ConfigDb` backing store) | host `14330` → container `1433` |
|
||||
| `ldap` | OpenLDAP with dev users `alice` / `bob` | host `3893` → container `1389` |
|
||||
| `admin-a` | OtOpcUa.Host, `OTOPCUA_ROLES=admin`, cluster seed | internal `9000` |
|
||||
| `admin-b` | OtOpcUa.Host, `OTOPCUA_ROLES=admin`, joins admin-a | internal `9000` |
|
||||
| `driver-a` | OtOpcUa.Host, `OTOPCUA_ROLES=driver` | host `4840` → container `4840` |
|
||||
| `driver-b` | OtOpcUa.Host, `OTOPCUA_ROLES=driver` | host `4841` → container `4840` |
|
||||
| `traefik` | Routes `:80` to whichever admin-* currently passes `/health/active` | host `80`, dashboard `8080` |
|
||||
| `sql` | SQL Server 2022 — single `OtOpcUa` ConfigDb shared by all three clusters | host `14330` → container `1433` |
|
||||
| `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8089` |
|
||||
|
||||
All six containers share an Akka cluster bound to port `4053` inside the Compose network. The Akka `PublicHostname` of each container matches its Compose service name; the seed-node list points at `admin-a` so the other three join via that.
|
||||
Authentication uses the **shared GLAuth** on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`). Every host container binds that instance via `cn=serviceaccount,dc=zb,dc=local`. `DevStubMode` is **not** active. Sign in as `multi-role` / `password` to get all three OtOpcUa roles (Administrator, Designer, Viewer), or use any other shared test user with password `password`. Group→role mappings are seeded by `seed/seed-clusters.sql` (`OtOpcUa-Admins`→Administrator, `OtOpcUa-Designers`→Designer, `OtOpcUa-Viewers`→Viewer). The shared GLAuth source of truth and deploy runbook live in `scadaproj/infra/glauth/`.
|
||||
|
||||
### Main cluster — split admin/driver roles
|
||||
|
||||
| Service | Role | Ports |
|
||||
|---|---|---|
|
||||
| `admin-a` | `OTOPCUA_ROLES=admin`, cluster seed | internal `9000` |
|
||||
| `admin-b` | `OTOPCUA_ROLES=admin`, joins admin-a | internal `9000` |
|
||||
| `driver-a` | `OTOPCUA_ROLES=driver` | host `4840` → container `4840` |
|
||||
| `driver-b` | `OTOPCUA_ROLES=driver` | host `4841` → container `4840` |
|
||||
|
||||
### Site A cluster — 2-node fused admin+driver
|
||||
|
||||
| Service | Role | Ports |
|
||||
|---|---|---|
|
||||
| `site-a-1` | `OTOPCUA_ROLES=admin,driver`, cluster seed | host `4842` → container `4840` |
|
||||
| `site-a-2` | `OTOPCUA_ROLES=admin,driver`, joins site-a-1 | host `4843` → container `4840` |
|
||||
|
||||
### Site B cluster — 2-node fused admin+driver
|
||||
|
||||
| Service | Role | Ports |
|
||||
|---|---|---|
|
||||
| `site-b-1` | `OTOPCUA_ROLES=admin,driver`, cluster seed | host `4844` → container `4840` |
|
||||
| `site-b-2` | `OTOPCUA_ROLES=admin,driver`, joins site-b-1 | host `4845` → container `4840` |
|
||||
|
||||
All containers bind Akka remoting to port `4053` inside their own network namespace; the `PublicHostname` of each matches its Compose service name. Akka mesh isolation is enforced purely by disjoint seed lists. Configuration-side isolation is enforced by `ServerCluster.ClusterId` — see "Multi-tenancy" below.
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
All eight host nodes write to the same `OtOpcUa` ConfigDb. The `ServerCluster` table differentiates the three Akka meshes: each Akka cluster maps to one row, and each `ClusterNode` row's `ClusterId` ties the runtime node back to its owning cluster scope.
|
||||
|
||||
A one-shot `cluster-seed` Compose service (image `mcr.microsoft.com/mssql-tools`) waits for SQL + the EF auto-migration to complete and then INSERTs the rows below. The seed is **idempotent** — `IF NOT EXISTS` guards every insert — so re-runs on `docker compose up` are no-ops:
|
||||
|
||||
| Akka mesh | `ServerCluster.ClusterId` | `ClusterNode.NodeId` rows |
|
||||
|---|---|---|
|
||||
| Main | `MAIN` | `driver-a`, `driver-b` (OPC UA publishers) |
|
||||
| Site A | `SITE-A` | `site-a-1`, `site-a-2` |
|
||||
| Site B | `SITE-B` | `site-b-1`, `site-b-2` |
|
||||
|
||||
`ClusterNode` is the table for **OPC UA-publishing nodes** (not every Akka cluster member), which is why the main cluster's `admin-a` / `admin-b` don't get rows — they're control-plane-only.
|
||||
|
||||
Each `ClusterNode.NodeId` matches the node's `Cluster__PublicHostname` env value (Compose service name) — that's the lookup the runtime uses to resolve its own membership. `ApplicationUri` follows the `urn:OtOpcUa:<NodeId>` convention.
|
||||
|
||||
The SQL lives at `seed/seed-clusters.sql`; the wait-and-apply wrapper lives at `seed/entrypoint.sh`. To re-seed manually:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-dev/docker-compose.yml run --rm cluster-seed
|
||||
```
|
||||
|
||||
### Galaxy / MxAccess gateway
|
||||
|
||||
The seed also pre-creates a `SystemPlatform` Namespace + a `GalaxyMxGateway` DriverInstance in the MAIN cluster pointing at `http://10.100.0.48:5120`. The API key is resolved from the `GALAXY_MXGW_API_KEY` env var set on every driver-role container in compose; override via `GALAXY_MXGW_API_KEY=... docker compose up -d` to swap keys without editing the compose file.
|
||||
|
||||
The DriverHost actor doesn't spawn drivers from raw DriverInstance rows on its own — the v2 deploy lifecycle requires a *sealed Deployment* before drivers materialise. After first bring-up, sign in to the Admin UI and click **Deploy current configuration** on `/deployments` to compose the seeded rows into an artifact and dispatch it. The Galaxy driver instance will start its gRPC connection to the gateway on the next deploy ack.
|
||||
|
||||
## Bring up
|
||||
|
||||
@@ -22,24 +72,21 @@ All six containers share an Akka cluster bound to port `4053` inside the Compose
|
||||
# from the repo root
|
||||
docker compose -f docker-dev/docker-compose.yml up -d --build
|
||||
|
||||
# wait ~15 seconds for SQL to come up + the cluster to form
|
||||
# wait ~20 seconds for SQL to come up + all three clusters to form
|
||||
|
||||
open http://localhost # Blazor admin UI via Traefik
|
||||
open http://localhost:8080 # Traefik dashboard
|
||||
open http://localhost # main cluster admin UI
|
||||
open http://site-a.localhost # site A admin UI
|
||||
open http://site-b.localhost # site B admin UI
|
||||
open http://localhost:8089 # Traefik dashboard
|
||||
```
|
||||
|
||||
On macOS, `*.localhost` resolves to `127.0.0.1` automatically. On Linux add `127.0.0.1 site-a.localhost site-b.localhost` to `/etc/hosts` if your resolver doesn't.
|
||||
|
||||
The first build takes a few minutes (.NET SDK image + restore + publish). Subsequent rebuilds are faster with Docker's layer cache.
|
||||
|
||||
## Auth (dev only)
|
||||
|
||||
Use one of the LDAP dev users from `LDAP_USERS` in `docker-compose.yml`:
|
||||
|
||||
| Username | Password |
|
||||
|---|---|
|
||||
| `alice` | `alice123` |
|
||||
| `bob` | `bob123` |
|
||||
|
||||
The compose mounts everyone into `ou=FleetAdmin` so the dev role mapping resolves to `FleetAdmin`.
|
||||
All host containers authenticate against the shared GLAuth at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`). `DevStubMode` is **not** active. Sign in with any test user (password `password`); `multi-role` / `password` returns all three roles (Administrator, Designer, Viewer). Group→role mappings are seeded by `seed/seed-clusters.sql`. The GLAuth source of truth + deploy runbook is in `scadaproj/infra/glauth/`. **Do not** enable `DevStubMode` outside local debugging — production must always bind a real LDAP backend.
|
||||
|
||||
## Tear down
|
||||
|
||||
@@ -47,16 +94,19 @@ The compose mounts everyone into `ou=FleetAdmin` so the dev role mapping resolve
|
||||
docker compose -f docker-dev/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
The `-v` drops the SQL + LDAP volumes; remove it to keep ConfigDb state across restarts.
|
||||
The `-v` drops the SQL volume; remove it to keep ConfigDb state across restarts. There is no local LDAP volume — LDAP is the shared external GLAuth on `10.100.0.35:3893`.
|
||||
|
||||
## Failover smoke
|
||||
|
||||
1. Watch the Traefik dashboard at `http://localhost:8080`. Both `admin-a` and `admin-b` should be listed as healthy in the `otopcua-admin` service.
|
||||
1. Watch the Traefik dashboard at `http://localhost:8089`. Both `admin-a` and `admin-b` should be listed as healthy in the `otopcua-admin` service.
|
||||
2. `docker compose -f docker-dev/docker-compose.yml stop admin-a` — `admin-b` should pick up the admin role-leader within ~15 s (Akka split-brain stable-after). Traefik will route traffic to `admin-b` once its `/health/active` returns 200.
|
||||
3. `docker compose -f docker-dev/docker-compose.yml start admin-a` — `admin-a` rejoins as a follower; `admin-b` keeps the leader role until something disturbs it.
|
||||
|
||||
## Notes
|
||||
|
||||
- This compose is for the **local Mac/Linux developer rig**. The team's CI + soak runs go to the remote docker host at `10.100.0.35` (see `docs/v2/dev-environment.md`); the file there mirrors this one with adjusted port bindings.
|
||||
- The OPC UA driver endpoints (`opc.tcp://localhost:4840`, `opc.tcp://localhost:4841`) are reachable directly from the host — Traefik is only in front of the admin HTTP surface.
|
||||
- The OPC UA driver endpoints are reachable directly from the host (Traefik is only in front of the admin HTTP surface):
|
||||
- Main: `opc.tcp://localhost:4840` (driver-a), `opc.tcp://localhost:4841` (driver-b)
|
||||
- Site A: `opc.tcp://localhost:4842` (site-a-1), `opc.tcp://localhost:4843` (site-a-2)
|
||||
- Site B: `opc.tcp://localhost:4844` (site-b-1), `opc.tcp://localhost:4845` (site-b-2)
|
||||
- Galaxy + Wonderware drivers can't run in Linux containers (they need the Windows-only mxaccessgw + Historian SDK). On non-Windows, `DriverInstanceActor.ShouldStub(driverType, roles)` returns `true` for those types and the actor goes straight to a `Stubbed` state that returns deterministic success.
|
||||
|
||||
+226
-30
@@ -1,18 +1,41 @@
|
||||
# docker-dev/ — Mac-friendly four-node fleet for v2 development + manual UI exercise.
|
||||
# docker-dev/ — Mac-friendly multi-cluster fleet for v2 development + manual UI exercise.
|
||||
#
|
||||
# Stack:
|
||||
# sql SQL Server 2022 (ConfigDb backing store)
|
||||
# ldap OpenLDAP with the dev users from C:\publish\glauth\auth.md mirrored in
|
||||
# admin-a OtOpcUa.Host with OTOPCUA_ROLES=admin (cluster seed)
|
||||
# admin-b OtOpcUa.Host with OTOPCUA_ROLES=admin (joins admin-a)
|
||||
# driver-a OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
||||
# driver-b OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
||||
# traefik Routes :80 to whichever admin-* currently passes /health/active
|
||||
# Stack (3 separate Akka clusters — all share the single `OtOpcUa` ConfigDb):
|
||||
# sql SQL Server 2022 — hosts the one ConfigDb that all three clusters use
|
||||
# ldap OpenLDAP with the dev users from C:\publish\glauth\auth.md mirrored in
|
||||
#
|
||||
# Main cluster (existing — split-role admin / driver pair on a single Akka mesh):
|
||||
# admin-a OtOpcUa.Host with OTOPCUA_ROLES=admin (seed)
|
||||
# admin-b OtOpcUa.Host with OTOPCUA_ROLES=admin (joins admin-a)
|
||||
# driver-a OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
||||
# driver-b OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a)
|
||||
#
|
||||
# Site A cluster (2-node fused admin+driver):
|
||||
# site-a-1, site-a-2 OTOPCUA_ROLES=admin,driver, seed = site-a-1
|
||||
#
|
||||
# Site B cluster (2-node fused admin+driver):
|
||||
# site-b-1, site-b-2 OTOPCUA_ROLES=admin,driver, seed = site-b-1
|
||||
#
|
||||
# traefik PathPrefix → main cluster admin-a/admin-b; Host(`site-a.localhost`) →
|
||||
# site-a-*; Host(`site-b.localhost`) → site-b-*. Add the two site hosts to
|
||||
# your /etc/hosts (or rely on macOS `.localhost` auto-resolution).
|
||||
#
|
||||
# Multi-tenancy: ConfigDb is one schema with a `ServerCluster` table; each Akka cluster
|
||||
# corresponds to a row in it (ClusterId = "MAIN" / "SITE-A" / "SITE-B"), and each node's
|
||||
# `ClusterNode.NodeId` points back at the row that owns it. After first boot, sign in to
|
||||
# any cluster's Admin UI and create the matching ServerCluster + ClusterNode rows via
|
||||
# /clusters and /hosts so the runtime knows what configuration scope applies.
|
||||
#
|
||||
# Akka mesh isolation: same system name "otopcua" + same remoting port 4053 inside each
|
||||
# container's own network namespace, but with disjoint seed-node lists — gossip never
|
||||
# crosses between the three meshes.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-dev/docker-compose.yml up -d --build
|
||||
# open http://localhost # Blazor admin UI via Traefik
|
||||
# open http://localhost:8080 # Traefik dashboard
|
||||
# open http://localhost # main cluster Blazor admin UI
|
||||
# open http://site-a.localhost # site A admin UI
|
||||
# open http://site-b.localhost # site B admin UI
|
||||
# open http://localhost:8089 # Traefik dashboard (8080 is the sister scadalink stack)
|
||||
#
|
||||
# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v
|
||||
|
||||
@@ -28,23 +51,39 @@ services:
|
||||
MSSQL_PID: Developer
|
||||
ports:
|
||||
- "14330:1433"
|
||||
# Persist the ConfigDb across container recreates. Without this the dev SQL
|
||||
# is ephemeral (container writable layer), so a recreate silently drops the
|
||||
# OtOpcUa database and every host node fails its configdb health check until
|
||||
# EF auto-migration + cluster-seed rebuild it. The named volume keeps the
|
||||
# schema + seeded clusters between `docker compose up` cycles.
|
||||
volumes:
|
||||
- otopcua-mssql-data:/var/opt/mssql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Dev123' -No -Q 'SELECT 1' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
ldap:
|
||||
image: bitnami/openldap:2.6
|
||||
environment:
|
||||
LDAP_ROOT: "dc=lmxopcua,dc=local"
|
||||
LDAP_ADMIN_USERNAME: "admin"
|
||||
LDAP_ADMIN_PASSWORD: "ldapadmin"
|
||||
LDAP_USERS: "alice,bob"
|
||||
LDAP_PASSWORDS: "alice123,bob123"
|
||||
LDAP_USER_DC: "ou=FleetAdmin"
|
||||
ports:
|
||||
- "3893:1389"
|
||||
# ── Cluster seed (one-shot) ────────────────────────────────────────────────
|
||||
# Waits for SQL + the host containers' EF auto-migration, then INSERTs the
|
||||
# three ServerCluster rows and the six ClusterNode rows that scope each Akka
|
||||
# mesh inside the shared OtOpcUa ConfigDb. Idempotent — re-runs are no-ops.
|
||||
cluster-seed:
|
||||
image: mcr.microsoft.com/mssql-tools:latest
|
||||
depends_on:
|
||||
sql:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./seed:/seed:ro
|
||||
entrypoint: ["/bin/bash", "/seed/entrypoint.sh"]
|
||||
restart: "no"
|
||||
|
||||
# OpenLDAP was previously here but the bitnami/openldap:2.6 image was retired
|
||||
# (manifest gone) and bitnamilegacy/openldap:2.6 crashes during LDIF setup with
|
||||
# exit 68. For the dev compose every host container now runs with
|
||||
# Security__Ldap__DevStubMode=true, so any non-empty username/password
|
||||
# signs in as `Administrator`. Restore a real LDAP service when there's a need
|
||||
# for end-to-end LDAP coverage (the host code path is unchanged).
|
||||
|
||||
admin-a: &otopcua-host
|
||||
build:
|
||||
@@ -65,9 +104,17 @@ services:
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__Server: "ldap"
|
||||
Authentication__Ldap__Port: "1389"
|
||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
||||
Security__Ldap__Enabled: "true"
|
||||
Security__Ldap__DevStubMode: "false"
|
||||
Security__Ldap__Server: "10.100.0.35"
|
||||
Security__Ldap__Port: "3893"
|
||||
Security__Ldap__Transport: "None"
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
|
||||
admin-b:
|
||||
<<: *otopcua-host
|
||||
@@ -83,9 +130,17 @@ services:
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__Server: "ldap"
|
||||
Authentication__Ldap__Port: "1389"
|
||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
||||
Security__Ldap__Enabled: "true"
|
||||
Security__Ldap__DevStubMode: "false"
|
||||
Security__Ldap__Server: "10.100.0.35"
|
||||
Security__Ldap__Port: "3893"
|
||||
Security__Ldap__Transport: "None"
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
|
||||
driver-a:
|
||||
<<: *otopcua-host
|
||||
@@ -97,6 +152,9 @@ services:
|
||||
Cluster__PublicHostname: "driver-a"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4840:4840"
|
||||
|
||||
@@ -110,9 +168,139 @@ services:
|
||||
Cluster__PublicHostname: "driver-b"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4841:4840"
|
||||
|
||||
# ── Site A cluster (2-node fused admin+driver) ──────────────────────────────
|
||||
# Shares the OtOpcUa ConfigDb with the main + site-b clusters; multi-tenancy is
|
||||
# enforced by ServerCluster.ClusterId rows (configure via /clusters after boot).
|
||||
# Akka isolation comes from the disjoint seed list (seed = site-a-1).
|
||||
|
||||
site-a-1:
|
||||
<<: *otopcua-host
|
||||
environment:
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "site-a-1"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-1:4053"
|
||||
Cluster__Roles__0: "admin"
|
||||
Cluster__Roles__1: "driver"
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Security__Ldap__Enabled: "true"
|
||||
Security__Ldap__DevStubMode: "false"
|
||||
Security__Ldap__Server: "10.100.0.35"
|
||||
Security__Ldap__Port: "3893"
|
||||
Security__Ldap__Transport: "None"
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4842:4840"
|
||||
|
||||
site-a-2:
|
||||
<<: *otopcua-host
|
||||
depends_on:
|
||||
sql: { condition: service_healthy }
|
||||
site-a-1: { condition: service_started }
|
||||
environment:
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "site-a-2"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-1:4053"
|
||||
Cluster__Roles__0: "admin"
|
||||
Cluster__Roles__1: "driver"
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Security__Ldap__Enabled: "true"
|
||||
Security__Ldap__DevStubMode: "false"
|
||||
Security__Ldap__Server: "10.100.0.35"
|
||||
Security__Ldap__Port: "3893"
|
||||
Security__Ldap__Transport: "None"
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4843:4840"
|
||||
|
||||
# ── Site B cluster (2-node fused admin+driver) ──────────────────────────────
|
||||
|
||||
site-b-1:
|
||||
<<: *otopcua-host
|
||||
environment:
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "site-b-1"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-b-1:4053"
|
||||
Cluster__Roles__0: "admin"
|
||||
Cluster__Roles__1: "driver"
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Security__Ldap__Enabled: "true"
|
||||
Security__Ldap__DevStubMode: "false"
|
||||
Security__Ldap__Server: "10.100.0.35"
|
||||
Security__Ldap__Port: "3893"
|
||||
Security__Ldap__Transport: "None"
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4844:4840"
|
||||
|
||||
site-b-2:
|
||||
<<: *otopcua-host
|
||||
depends_on:
|
||||
sql: { condition: service_healthy }
|
||||
site-b-1: { condition: service_started }
|
||||
environment:
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "site-b-2"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-b-1:4053"
|
||||
Cluster__Roles__0: "admin"
|
||||
Cluster__Roles__1: "driver"
|
||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Security__Ldap__Enabled: "true"
|
||||
Security__Ldap__DevStubMode: "false"
|
||||
Security__Ldap__Server: "10.100.0.35"
|
||||
Security__Ldap__Port: "3893"
|
||||
Security__Ldap__Transport: "None"
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4845:4840"
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
command:
|
||||
@@ -121,10 +309,18 @@ services:
|
||||
- --providers.file.watch=true
|
||||
- --api.insecure=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8080:8080"
|
||||
- "9200:80" # host port 9200 → traefik :80 entrypoint (80 conflicts with scadabridge-traefik)
|
||||
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
|
||||
volumes:
|
||||
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||
depends_on:
|
||||
- admin-a
|
||||
- admin-b
|
||||
- site-a-1
|
||||
- site-a-2
|
||||
- site-b-1
|
||||
- site-b-2
|
||||
|
||||
volumes:
|
||||
# SQL Server data dir — persists the OtOpcUa ConfigDb across container recreates.
|
||||
otopcua-mssql-data:
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# docker-dev cluster-seed entrypoint. Waits for the OtOpcUa ConfigDb schema to
|
||||
# be in place, then applies the idempotent row seed.
|
||||
#
|
||||
# IMPORTANT: this container does NOT run EF migrations — sqlcmd can't execute
|
||||
# the V2 migration script cleanly because it contains CREATE PROCEDURE
|
||||
# statements inside IF NOT EXISTS BEGIN ... END blocks (procs must be the
|
||||
# first statement in their batch). Migrations are owned by the operator:
|
||||
#
|
||||
# dotnet ef database update \
|
||||
# --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration \
|
||||
# --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host
|
||||
#
|
||||
# (with ConnectionStrings__ConfigDb pointing at Server=localhost,14330;...).
|
||||
# Once the schema is in place, restart the cluster-seed container — or just
|
||||
# `docker compose up -d` and the seed will pick up where it left off thanks to
|
||||
# the IF NOT EXISTS guards in seed-clusters.sql.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SQLCMD="/opt/mssql-tools/bin/sqlcmd"
|
||||
SERVER="${SQL_HOST:-sql},1433"
|
||||
USER="${SQL_USER:-sa}"
|
||||
PASS="${SQL_PASSWORD:-OtOpcUa!Dev123}"
|
||||
DB="${SQL_DATABASE:-OtOpcUa}"
|
||||
|
||||
run_sql_in() {
|
||||
local target_db="$1"; shift
|
||||
# -I forces SET QUOTED_IDENTIFIER ON (needed for filtered indexes if you
|
||||
# ever extend this script to touch them).
|
||||
"$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$target_db" -b -h -1 -I "$@"
|
||||
}
|
||||
|
||||
echo "[cluster-seed] waiting for SQL Server to accept connections..."
|
||||
until run_sql_in master -Q "SELECT 1" >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
echo "[cluster-seed] SQL Server up."
|
||||
|
||||
echo "[cluster-seed] waiting for ${DB} database + dbo.ServerCluster table (operator must run dotnet ef database update)..."
|
||||
until run_sql_in "$DB" -Q "IF OBJECT_ID('dbo.ServerCluster') IS NULL THROW 50001, 'missing', 1; SELECT 1" >/dev/null 2>&1; do
|
||||
sleep 3
|
||||
done
|
||||
echo "[cluster-seed] schema ready."
|
||||
|
||||
echo "[cluster-seed] applying seed-clusters.sql (ServerCluster + ClusterNode rows)..."
|
||||
run_sql_in "$DB" -i /seed/seed-clusters.sql
|
||||
echo "[cluster-seed] done."
|
||||
@@ -0,0 +1,217 @@
|
||||
-- docker-dev cluster seed. Idempotent — safe to re-run on every `docker compose up`.
|
||||
--
|
||||
-- Populates:
|
||||
-- ServerCluster MAIN, SITE-A, SITE-B
|
||||
-- ClusterNode driver-a, driver-b → MAIN
|
||||
-- site-a-1, site-a-2 → SITE-A
|
||||
-- site-b-1, site-b-2 → SITE-B
|
||||
--
|
||||
-- ServerCluster.NodeCount + RedundancyMode are coupled by CHECK constraint:
|
||||
-- NodeCount=1 ⇒ RedundancyMode='None'
|
||||
-- NodeCount=2 ⇒ RedundancyMode∈('Warm','Hot')
|
||||
--
|
||||
-- Each ClusterNode.ApplicationUri MUST be globally unique (UX_ClusterNode_ApplicationUri).
|
||||
-- Convention: urn:OtOpcUa:<NodeId>.
|
||||
--
|
||||
-- Host = Compose service name (resolves inside the otopcua-dev network).
|
||||
-- OpcUaPort stays at the container-internal 4840; the host-side port mapping is in
|
||||
-- docker-compose.yml ports: blocks and is irrelevant to ClusterNode rows.
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- ServerCluster
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'MAIN')
|
||||
INSERT INTO dbo.ServerCluster
|
||||
(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy)
|
||||
VALUES
|
||||
('MAIN', 'Main cluster', 'zb', 'docker-dev',
|
||||
2, 'Warm', 1,
|
||||
'docker-dev seed — admin-a/admin-b control-plane, driver-a/driver-b OPC UA publishers.',
|
||||
'docker-dev-seed');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-A')
|
||||
INSERT INTO dbo.ServerCluster
|
||||
(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy)
|
||||
VALUES
|
||||
('SITE-A', 'Site A', 'zb', 'site-a',
|
||||
2, 'Warm', 1,
|
||||
'docker-dev seed — 2-node fused admin+driver cluster.',
|
||||
'docker-dev-seed');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-B')
|
||||
INSERT INTO dbo.ServerCluster
|
||||
(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy)
|
||||
VALUES
|
||||
('SITE-B', 'Site B', 'zb', 'site-b',
|
||||
2, 'Warm', 1,
|
||||
'docker-dev seed — 2-node fused admin+driver cluster.',
|
||||
'docker-dev-seed');
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- ClusterNode — main cluster OPC UA publishers
|
||||
--
|
||||
-- NodeId is "<compose-service>:4053" so it matches what ClusterRoleInfo +
|
||||
-- ConfigPublishCoordinator derive from Akka.Cluster.Get(system).State.Members
|
||||
-- (member.Address.Host:Port). NodeDeploymentState.NodeId is FK-bound to
|
||||
-- ClusterNode.NodeId; mismatched values cause FK 547 on deploy.
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a:4053')
|
||||
INSERT INTO dbo.ClusterNode
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('driver-a:4053', 'MAIN', 'driver-a', 4840, 8081, 'urn:OtOpcUa:driver-a', 200, 1, 'docker-dev-seed');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-b:4053')
|
||||
INSERT INTO dbo.ClusterNode
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('driver-b:4053', 'MAIN', 'driver-b', 4840, 8081, 'urn:OtOpcUa:driver-b', 150, 1, 'docker-dev-seed');
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- ClusterNode — site A
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-1:4053')
|
||||
INSERT INTO dbo.ClusterNode
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('site-a-1:4053', 'SITE-A', 'site-a-1', 4840, 8081, 'urn:OtOpcUa:site-a-1', 200, 1, 'docker-dev-seed');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-2:4053')
|
||||
INSERT INTO dbo.ClusterNode
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('site-a-2:4053', 'SITE-A', 'site-a-2', 4840, 8081, 'urn:OtOpcUa:site-a-2', 150, 1, 'docker-dev-seed');
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- ClusterNode — site B
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-1:4053')
|
||||
INSERT INTO dbo.ClusterNode
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('site-b-1:4053', 'SITE-B', 'site-b-1', 4840, 8081, 'urn:OtOpcUa:site-b-1', 200, 1, 'docker-dev-seed');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
|
||||
INSERT INTO dbo.ClusterNode
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Galaxy MxAccess gateway — MAIN cluster
|
||||
--
|
||||
-- Namespace.Kind=SystemPlatform is required for Galaxy/MXAccess data per
|
||||
-- decision #107; raw equipment drivers use Equipment. DriverInstance points
|
||||
-- at the external mxaccessgw process. The driver code lives in this repo
|
||||
-- (.NET 10, cross-platform); only the gateway worker needs Windows.
|
||||
--
|
||||
-- ApiKeySecretRef = env:GALAXY_MXGW_API_KEY → resolved at runtime by
|
||||
-- GalaxyDriver.ResolveApiKey. The env var is set on every driver-role
|
||||
-- container in docker-compose.yml.
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Namespace WHERE NamespaceId = 'MAIN-galaxy')
|
||||
INSERT INTO dbo.Namespace
|
||||
(NamespaceRowId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
|
||||
VALUES
|
||||
(NEWID(), 'MAIN-galaxy', 'MAIN', 'SystemPlatform',
|
||||
'urn:zb:docker-dev:galaxy', 1,
|
||||
'docker-dev seed — Galaxy / MXAccess namespace served by the MAIN cluster.');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-galaxy-mxgw')
|
||||
INSERT INTO dbo.DriverInstance
|
||||
(DriverInstanceRowId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
|
||||
VALUES
|
||||
(NEWID(), 'MAIN-galaxy-mxgw', 'MAIN', 'MAIN-galaxy',
|
||||
'MxAccess gateway (10.100.0.48:5120)', 'GalaxyMxGateway', 1,
|
||||
N'{
|
||||
"Gateway": {
|
||||
"Endpoint": "http://10.100.0.48:5120",
|
||||
"ApiKeySecretRef": "env:GALAXY_MXGW_API_KEY",
|
||||
"UseTls": false,
|
||||
"ConnectTimeoutSeconds": 10,
|
||||
"DefaultCallTimeoutSeconds": 30
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "OtOpcUa-MAIN-docker-dev",
|
||||
"PublishingIntervalMs": 1000
|
||||
},
|
||||
"Repository": {
|
||||
"DiscoverPageSize": 5000,
|
||||
"WatchDeployEvents": true
|
||||
},
|
||||
"Reconnect": {
|
||||
"InitialBackoffMs": 500,
|
||||
"MaxBackoffMs": 30000,
|
||||
"ReplayOnSessionLost": true
|
||||
}
|
||||
}');
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Galaxy test tags — TestMachine_001.TestAlarm001..003
|
||||
--
|
||||
-- SystemPlatform-namespace tags have EquipmentId=NULL and use FolderPath +
|
||||
-- Name to address the MXAccess item. The Galaxy driver subscribes via the
|
||||
-- "FolderPath.Name" MXAccess reference form; OPC UA browse path is the
|
||||
-- equivalent "FolderPath/Name" under the SystemPlatform namespace.
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm001')
|
||||
INSERT INTO dbo.Tag
|
||||
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||
VALUES
|
||||
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm001', 'MAIN-galaxy-mxgw', NULL, NULL,
|
||||
'TestAlarm001', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm002')
|
||||
INSERT INTO dbo.Tag
|
||||
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||
VALUES
|
||||
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm002', 'MAIN-galaxy-mxgw', NULL, NULL,
|
||||
'TestAlarm002', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm003')
|
||||
INSERT INTO dbo.Tag
|
||||
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||
VALUES
|
||||
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm003', 'MAIN-galaxy-mxgw', NULL, NULL,
|
||||
'TestAlarm003', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Summary (logged by sqlcmd output)
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId;
|
||||
SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase
|
||||
FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;
|
||||
SELECT NamespaceId, ClusterId, Kind, NamespaceUri FROM dbo.Namespace ORDER BY ClusterId, NamespaceId;
|
||||
SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name
|
||||
FROM dbo.DriverInstance ORDER BY ClusterId, DriverInstanceId;
|
||||
SELECT TagId, DriverInstanceId, FolderPath, Name, DataType FROM dbo.Tag ORDER BY DriverInstanceId, FolderPath, Name;
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- LDAP group -> AdminUI role mappings (shared dev GLAuth, 10.100.0.35)
|
||||
-- System-wide (ClusterId NULL, IsSystemWide 1). Group keys are the BARE RDN
|
||||
-- names the shared ZB.MOM.WW.Auth.Ldap returns (LdapAuthService.ToGroupShortName
|
||||
-- = first-RDN value), e.g. memberOf ou=OtOpcUa-Admins,... -> "OtOpcUa-Admins".
|
||||
-- Role is stored as the AdminRole enum NAME (HasConversion<string>).
|
||||
-- QUOTED_IDENTIFIER ON is required because the table has a filtered unique index.
|
||||
------------------------------------------------------------------------------
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Admins' AND ClusterId IS NULL)
|
||||
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
|
||||
VALUES (NEWID(), 'OtOpcUa-Admins', 'Administrator', NULL, 1, SYSUTCDATETIME(), N'shared-glauth dev seed');
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Designers' AND ClusterId IS NULL)
|
||||
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
|
||||
VALUES (NEWID(), 'OtOpcUa-Designers', 'Designer', NULL, 1, SYSUTCDATETIME(), N'shared-glauth dev seed');
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Viewers' AND ClusterId IS NULL)
|
||||
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
|
||||
VALUES (NEWID(), 'OtOpcUa-Viewers', 'Viewer', NULL, 1, SYSUTCDATETIME(), N'shared-glauth dev seed');
|
||||
|
||||
SELECT LdapGroup, Role, IsSystemWide FROM dbo.LdapGroupRoleMapping ORDER BY LdapGroup;
|
||||
@@ -1,6 +1,12 @@
|
||||
# docker-dev companion to scripts/install/traefik-dynamic.yml. Same routing rules,
|
||||
# but the upstream targets are the Compose service names (admin-a, admin-b) on
|
||||
# port 9000 instead of the Windows hostnames a bare-metal deployment would use.
|
||||
# docker-dev companion to scripts/install/traefik-dynamic.yml. Routes three
|
||||
# Akka clusters that share the Compose network:
|
||||
#
|
||||
# - Main cluster (default): PathPrefix(`/`) → admin-a / admin-b.
|
||||
# - Site A cluster: Host(`site-a.localhost`) → site-a-1 / site-a-2.
|
||||
# - Site B cluster: Host(`site-b.localhost`) → site-b-1 / site-b-2.
|
||||
#
|
||||
# Host-header rules are more specific than PathPrefix, so they win over the
|
||||
# default router for the site hostnames automatically — no priority field needed.
|
||||
|
||||
http:
|
||||
routers:
|
||||
@@ -9,9 +15,27 @@ http:
|
||||
rule: "PathPrefix(`/`)"
|
||||
service: otopcua-admin
|
||||
|
||||
otopcua-site-a:
|
||||
entryPoints: ["web"]
|
||||
rule: "Host(`site-a.localhost`)"
|
||||
service: otopcua-site-a
|
||||
|
||||
otopcua-site-b:
|
||||
entryPoints: ["web"]
|
||||
rule: "Host(`site-b.localhost`)"
|
||||
service: otopcua-site-b
|
||||
|
||||
services:
|
||||
otopcua-admin:
|
||||
loadBalancer:
|
||||
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
|
||||
# backend that owns the circuit ID. Sticky cookie keeps each session
|
||||
# pinned to one node so the post-handshake WebSocket doesn't 404.
|
||||
sticky:
|
||||
cookie:
|
||||
name: otopcua_lb
|
||||
httpOnly: true
|
||||
sameSite: lax
|
||||
servers:
|
||||
- url: "http://admin-a:9000"
|
||||
- url: "http://admin-b:9000"
|
||||
@@ -19,3 +43,39 @@ http:
|
||||
path: /health/active
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
|
||||
otopcua-site-a:
|
||||
loadBalancer:
|
||||
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
|
||||
# backend that owns the circuit ID. Sticky cookie keeps each session
|
||||
# pinned to one node so the post-handshake WebSocket doesn't 404.
|
||||
sticky:
|
||||
cookie:
|
||||
name: otopcua_lb
|
||||
httpOnly: true
|
||||
sameSite: lax
|
||||
servers:
|
||||
- url: "http://site-a-1:9000"
|
||||
- url: "http://site-a-2:9000"
|
||||
healthCheck:
|
||||
path: /health/active
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
|
||||
otopcua-site-b:
|
||||
loadBalancer:
|
||||
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
|
||||
# backend that owns the circuit ID. Sticky cookie keeps each session
|
||||
# pinned to one node so the post-handshake WebSocket doesn't 404.
|
||||
sticky:
|
||||
cookie:
|
||||
name: otopcua_lb
|
||||
httpOnly: true
|
||||
sameSite: lax
|
||||
servers:
|
||||
- url: "http://site-b-1:9000"
|
||||
- url: "http://site-b-2:9000"
|
||||
healthCheck:
|
||||
path: /health/active
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
|
||||
+27
-25
@@ -1,10 +1,10 @@
|
||||
# Address Space
|
||||
|
||||
Each driver's browsable subtree is built by streaming nodes from the driver's `ITagDiscovery.DiscoverAsync` implementation into an `IAddressSpaceBuilder`. `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) owns the shared orchestration; `DriverNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) implements `IAddressSpaceBuilder` against the OPC Foundation stack's `CustomNodeManager2`. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
|
||||
Address-space construction is a two-layer system. The **driver-facing layer** is the streaming builder: a driver implements `ITagDiscovery.DiscoverAsync` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`) and emits `Folder` / `Variable` / `AddProperty` calls into an `IAddressSpaceBuilder` as it walks its backend — no buffering of the whole tree. `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) wraps that builder to capture alarm-condition sinks and routes alarm events from the driver to them. The **SDK materialization layer** turns the resulting node descriptions into live OPC UA nodes: `OpcUaPublishActor` drives the write-only `IOpcUaAddressSpaceSink`, whose production binding `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) forwards to `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`), a `CustomNodeManager2` subclass that owns the `FolderState` / `BaseDataVariableState` instances. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
|
||||
|
||||
## Driver root folder
|
||||
## Root folder
|
||||
|
||||
Every driver's subtree starts with a root `FolderState` under the standard OPC UA `Objects` folder, wired with an `Organizes` reference. `DriverNodeManager.CreateAddressSpace` creates this folder with `NodeId = ns;s={DriverInstanceId}`, `BrowseName = {DriverInstanceId}`, and `EventNotifier = SubscribeToEvents | HistoryRead` so alarm and history-event subscriptions can target the root. The namespace URI is `urn:OtOpcUa:{DriverInstanceId}`.
|
||||
`OtOpcUaNodeManager.CreateAddressSpace` creates a single shared root `FolderState` (`NodeId = OtOpcUa`, `BrowseName = OtOpcUa`, `EventNotifier = None`) under the standard OPC UA `Objects` folder, wired with an `Organizes` reference. Every driver's folders and variables hang beneath this one root; the server is published under a single `ApplicationUri = urn:OtOpcUa` (the `OpcUaApplicationHostOptions.ApplicationUri` default) and all nodes live in the server's single custom namespace, not a per-driver `urn:OtOpcUa:{DriverInstanceId}`. The UNS Area → Line → Equipment folder skeleton under the root is materialised by `Phase7Applier.MaterialiseHierarchy` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs`); SystemPlatform (Galaxy) tags are materialised by `Phase7Applier.MaterialiseGalaxyTags`.
|
||||
|
||||
## IAddressSpaceBuilder surface
|
||||
|
||||
@@ -14,24 +14,24 @@ Every driver's subtree starts with a root `FolderState` under the standard OPC U
|
||||
- `Variable(browseName, displayName, DriverAttributeInfo attributeInfo)` — creates a `BaseDataVariableState` and returns an `IVariableHandle` the driver keeps for alarm wiring.
|
||||
- `AddProperty(browseName, DriverDataType, value)` — attaches a `PropertyState` for static metadata (e.g. equipment identification fields).
|
||||
|
||||
Drivers drive ordering. Typical pattern: root → folder per equipment → variables per tag. `GenericDriverNodeManager` calls `DiscoverAsync` once on startup and once per rediscovery cycle.
|
||||
Drivers drive ordering. Typical pattern: root → folder per equipment → variables per tag. `GenericDriverNodeManager.BuildAddressSpaceAsync` calls `DiscoverAsync` once on startup and once per rediscovery cycle, tearing down the previous alarm subscription and clearing its sink registry before each re-walk so a redeploy doesn't double-fire alarm events.
|
||||
|
||||
## DriverAttributeInfo → OPC UA variable
|
||||
|
||||
Each variable carries a `DriverAttributeInfo` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`):
|
||||
|
||||
| Field | OPC UA target |
|
||||
| Field | Role |
|
||||
|---|---|
|
||||
| `FullName` | `NodeId.Identifier` — used as the driver-side lookup key for Read/Write/Subscribe |
|
||||
| `DriverDataType` | mapped to a built-in `DataTypeIds.*` NodeId via `DriverNodeManager.MapDataType` |
|
||||
| `IsArray` | `ValueRank = OneDimension` when true, `Scalar` otherwise |
|
||||
| `ArrayDim` | declared array length, carried through as metadata |
|
||||
| `SecurityClass` | stored in `_securityByFullRef` for `WriteAuthzPolicy` gating on write |
|
||||
| `IsHistorized` | flips `AccessLevel.HistoryRead` + `Historizing = true` |
|
||||
| `FullName` | driver-side full reference used as the lookup key for Read/Write/Subscribe; also seeds the variable's string `NodeId` |
|
||||
| `DriverDataType` | resolved to a built-in `DataTypeIds.*` NodeId at materialization time — `OtOpcUaNodeManager.ResolveBuiltInDataType` maps the data-type name string; each driver first maps its native type into `DriverDataType` (e.g. Galaxy via `Browse/DataTypeMap.Map`) |
|
||||
| `IsArray` / `ArrayDim` | declared 1-D-array length carried as metadata; the Galaxy discoverer sets `ArrayDim` only when the gateway reports a positive dimension |
|
||||
| `SecurityClass` | write-authorization tier (`SecurityClassification`); enforced server-side by the `NodePermissions` ACL evaluator (`TriePermissionEvaluator`) mapping each `OpcUaOperation` to a required permission bit. The Galaxy driver also caches it per full reference (`_securityByFullRef`) to answer `GetSecurityClassification` |
|
||||
| `IsHistorized` | marks the attribute as feeding historian / HistoryRead |
|
||||
| `IsAlarm` | drives the `MarkAsAlarmCondition` pass (see below) |
|
||||
| `WriteIdempotent` | stored in `_writeIdempotentByFullRef`; fed to `CapabilityInvoker.ExecuteWriteAsync` |
|
||||
| `WriteIdempotent` | when true the attribute's writes are safe to replay, so the capability invoker may apply Polly retry; defaults false so pulses / acks / counters aren't auto-retried |
|
||||
| `Source` | `NodeSourceKind` discriminator (`Driver` / `Virtual` / `ScriptedAlarm`) that decides which subsystem dispatches the node's Read/Write/Subscribe |
|
||||
|
||||
The initial value stays `null` with `StatusCode = BadWaitingForInitialData` until the first Read or `ISubscribable.OnDataChange` push lands.
|
||||
The variable is created with `StatusCode = BadWaitingForInitialData` and a null value until the first Read or `ISubscribable.OnDataChange` push lands. Note the production SDK sink (`OtOpcUaNodeManager.EnsureVariable`) currently materialises every variable as `ValueRank = Scalar`, read-only `AccessLevel`, and `Historizing = false` — the `IsArray`/`IsHistorized` intent lives in `DriverAttributeInfo` but is not yet projected onto the SDK node.
|
||||
|
||||
## CapturingBuilder + alarm sink registration
|
||||
|
||||
@@ -39,34 +39,36 @@ The initial value stays `null` with `StatusCode = BadWaitingForInitialData` unti
|
||||
|
||||
## NodeId scheme
|
||||
|
||||
All nodes live in the driver's namespace (not a shared `ns=1`). Browse paths are driver-defined:
|
||||
All nodes share the server's single custom namespace (`NamespaceIndex`); NodeIds are string identifiers, not numeric. The string values come from the source rows / driver references — there is no per-driver namespace prefix:
|
||||
|
||||
| Node type | NodeId format | Example |
|
||||
| Node type | NodeId (string identifier) | Example |
|
||||
|---|---|---|
|
||||
| Driver root | `ns;s={DriverInstanceId}` | `urn:OtOpcUa:galaxy-01;s=galaxy-01` |
|
||||
| Folder | `ns;s={parent}/{browseName}` | `ns;s=galaxy-01/Area_001` |
|
||||
| Variable | `ns;s={DriverAttributeInfo.FullName}` | `ns;s=DelmiaReceiver_001.DownloadPath` |
|
||||
| Alarm condition | `ns;s={FullReference}.Condition` | `ns;s=DelmiaReceiver_001.Temperature.Condition` |
|
||||
| Shared root | `OtOpcUa` | `OtOpcUa` |
|
||||
| UNS Area / Line / Equipment folder | the Config-DB `UnsAreaId` / `UnsLineId` / `EquipmentId` | `EQ_Press_07` |
|
||||
| Galaxy tag variable | the MXAccess reference (`Phase7Applier` uses `GalaxyTagPlan.MxAccessRef`) | `DelmiaReceiver_001.DownloadPath` |
|
||||
| Equipment tag variable | the driver full reference from `DriverAttributeInfo.FullName` | driver-specific |
|
||||
|
||||
For Galaxy the `FullName` stays in the legacy `tag_name.AttributeName` format; Modbus uses `unit:register:type`; AB CIP uses the native `program:tag.member` path; etc. — the shape is the driver's choice.
|
||||
For Galaxy the variable `FullName` is the `tag_name.AttributeName` MXAccess reference; AB CIP uses `tag.Name` or `tag.Name.member` for UDT members; the shape is the driver's choice. Browse-path resolution (OPC UA `TranslateBrowsePathsToNodeIds`) is the canonical way clients map a browse path to one of these flat NodeIds.
|
||||
|
||||
## Per-driver hierarchy examples
|
||||
|
||||
- **Galaxy Proxy**: walks the DB-snapshot hierarchy (`GalaxyProxyDriver.DiscoverAsync`), streams Area objects as folders and non-area objects as variable-bearing folders, marks `IsAlarm = true` on attributes that have an `AlarmExtension` primitive. The v1 two-pass primitive-grouping logic is retained inside the Galaxy driver.
|
||||
- **Galaxy**: `GalaxyDriver.DiscoverAsync` delegates to `GalaxyDiscoverer` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs`), which walks the hierarchy from `IGalaxyHierarchySource` — one folder per Galaxy object (browse name = `contained_name`, falling back to `tag_name`), one variable per dynamic attribute (full reference = `tag_name.AttributeName`). It copies the gateway-supplied `IsAlarm` flag through to `DriverAttributeInfo` and, for alarm-bearing attributes, calls `MarkAsAlarmCondition` with the five sub-attribute refs built by `AlarmRefBuilder`.
|
||||
- **Modbus**: streams one folder per device, one variable per register range from `ModbusDriverOptions`. No alarm surface.
|
||||
- **AB CIP**: uses `AbCipTemplateCache` to enumerate user-defined types, streams a folder per program with variables keyed on the native tag path.
|
||||
- **OPC UA Client**: re-exposes a remote server's address space — browses the upstream and relays nodes through the builder.
|
||||
- **AB CIP**: `AbCipDriver.DiscoverAsync` emits an `AbCip` root, then a folder per configured device. Pre-declared tags become variables under the device folder; UDT (`Structure`) tags fan out into a sub-folder with one variable per member; when controller browse is enabled, `IAbCipTagEnumerator` adds discovered tags under a `Discovered/` sub-folder. (`AbCipTemplateCache` caches UDT layouts for the libplctag enumerator.)
|
||||
- **OPC UA Client**: re-exposes a remote server's address space — `OpcUaClientDriver.DiscoverAsync` browses the upstream from `BrowseRoot` into a `Remote` folder (pass 1), then batch-reads DataType/AccessLevel/ValueRank/Historizing per variable before registering them (pass 2).
|
||||
|
||||
See `docs/v2/driver-specs.md` for the per-driver discovery contracts.
|
||||
|
||||
## Rediscovery
|
||||
|
||||
Drivers that implement `IRediscoverable` fire `OnRediscoveryNeeded` when their backend signals a change (Galaxy: `time_of_last_deploy` advance; TwinCAT: symbol-version-changed; OPC UA Client: server namespace change). Core re-runs `DiscoverAsync` and diffs — see `docs/IncrementalSync.md`. Static drivers (Modbus, S7) don't implement `IRediscoverable`; their address space only changes when a new generation is published from the Config DB.
|
||||
Drivers that implement `IRediscoverable` fire `OnRediscoveryNeeded` when their backend signals a change. Galaxy's `DeployWatcher` raises it when the observed `time_of_last_deploy` advances; TwinCAT raises it on the ADS symbol-version-changed signal (`DeviceSymbolVersionInvalid`, error 1809). Core re-runs `DiscoverAsync` and diffs — see `docs/IncrementalSync.md`. Drivers that don't implement `IRediscoverable` (Modbus, S7, OPC UA Client) only change their address space when a new generation is published from the Config DB.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — orchestration + `CapturingBuilder`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — OPC UA materialization (`IAddressSpaceBuilder` impl + `NestedBuilder`)
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` — OPC UA materialization (write-only sink fed by the actor system)
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — materialises the UNS folder hierarchy + Galaxy tags into the sink
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` — walks Config-DB Equipment-namespace rows into the builder
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs` — builder contract
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs` — driver discovery capability
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs` — per-attribute descriptor
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Alarm Historian — store-and-forward SQLite sink
|
||||
|
||||
Reference for `ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian`
|
||||
([`src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/)),
|
||||
the durable local queue that historizes alarm transitions to AVEVA Historian
|
||||
without ever blocking the alarm engine or operator actions.
|
||||
|
||||
This is the *sink mechanics* doc. For how the three alarm sources converge on
|
||||
the OPC UA Part 9 surface and which alarms route here, see
|
||||
[AlarmTracking.md](AlarmTracking.md). For the historian client that drains this
|
||||
queue, see [DriverLifecycle.md](DriverLifecycle.md#ihistoriandatasource--server-side-historian-read-surface)
|
||||
and [ServiceHosting.md](ServiceHosting.md).
|
||||
|
||||
---
|
||||
|
||||
## Why store-and-forward
|
||||
|
||||
Scripted alarms (and any future non-Galaxy `IAlarmSource`, e.g. AB CIP ALMD)
|
||||
must reach AVEVA Historian, but the historian sidecar can be slow, busy, or
|
||||
disconnected. The sink decouples the alarm engine from historian reachability:
|
||||
every qualifying transition is committed to a **local SQLite queue first**, and
|
||||
a background drain worker forwards rows to the historian on a backoff-aware
|
||||
cadence. Operator acks and alarm-state transitions are never blocked waiting on
|
||||
the historian.
|
||||
|
||||
> Galaxy-native alarms with `$Alarm*` extensions reach AVEVA Historian directly
|
||||
> via System Platform's `HistorizeToAveva` toggle — they do **not** flow through
|
||||
> this sink. This path is exclusively for non-Galaxy alarm producers.
|
||||
|
||||
---
|
||||
|
||||
## Contracts
|
||||
|
||||
All in
|
||||
[`IAlarmHistorianSink.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs)
|
||||
unless noted.
|
||||
|
||||
- **`IAlarmHistorianSink`** — the intake contract. `EnqueueAsync(evt, ct)`
|
||||
durably enqueues an event and returns as soon as the queue row is committed
|
||||
(fire-and-forget from the engine's perspective; the sink must not block the
|
||||
emitting thread). `GetStatus()` returns a `HistorianSinkStatus` snapshot.
|
||||
- **`NullAlarmHistorianSink`** — the no-op default for tests and deployments
|
||||
that don't historize alarms. It is the default DI binding (registered in the
|
||||
Runtime's `AddOtOpcUaRuntime`); production overrides it with
|
||||
`SqliteStoreAndForwardSink`.
|
||||
- **`AlarmHistorianEvent`**
|
||||
([`AlarmHistorianEvent.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmHistorianEvent.cs))
|
||||
— the source-agnostic event record: `AlarmId`, `EquipmentPath` (UNS path,
|
||||
doubles as Historian's SourceNode), `AlarmName`, `AlarmTypeName` (Part 9
|
||||
subtype), `Severity`, `EventKind` (free-form transition string —
|
||||
"Activated"/"Cleared"/"Acknowledged"/etc.), `Message`, `User`, `Comment`,
|
||||
`TimestampUtc`.
|
||||
- **`IAlarmHistorianWriter`** — what the drain worker delegates writes to.
|
||||
`WriteBatchAsync(batch, ct)` returns one `HistorianWriteOutcome` per event,
|
||||
in order. Production binds this to `WonderwareHistorianClient` (the AVEVA
|
||||
Historian sidecar IPC client).
|
||||
- **`HistorianWriteOutcome`** — per-event drain result: `Ack` (persisted,
|
||||
remove from queue), `RetryPlease` (transient failure — leave queued, retry
|
||||
after backoff), `PermanentFail` (malformed/unrecoverable — move to
|
||||
dead-letter).
|
||||
- **`HistorianSinkStatus`** — diagnostic snapshot surfaced to the AdminUI and
|
||||
`/healthz`: `QueueDepth`, `DeadLetterDepth`, `LastDrainUtc`, `LastSuccessUtc`,
|
||||
`LastError`, `DrainState`, and `EvictedCount`.
|
||||
- **`HistorianDrainState`** — `Disabled` / `Idle` / `Draining` / `BackingOff`.
|
||||
|
||||
---
|
||||
|
||||
## SqliteStoreAndForwardSink
|
||||
|
||||
[`SqliteStoreAndForwardSink.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs)
|
||||
is the production `IAlarmHistorianSink`. Construction takes a SQLite database
|
||||
path, an `IAlarmHistorianWriter`, a logger, and optional `batchSize` (default
|
||||
100), `capacity` (default 1,000,000), `deadLetterRetention` (default 30 days),
|
||||
and a test clock.
|
||||
|
||||
### Queue table
|
||||
|
||||
The sink owns one SQLite table (created on construction, WAL journal mode):
|
||||
|
||||
```sql
|
||||
CREATE TABLE Queue (
|
||||
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
AlarmId TEXT NOT NULL,
|
||||
EnqueuedUtc TEXT NOT NULL,
|
||||
PayloadJson TEXT NOT NULL, -- JSON-serialized AlarmHistorianEvent
|
||||
AttemptCount INTEGER NOT NULL DEFAULT 0,
|
||||
LastAttemptUtc TEXT NULL,
|
||||
LastError TEXT NULL,
|
||||
DeadLettered INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IX_Queue_Drain ON Queue (DeadLettered, RowId);
|
||||
```
|
||||
|
||||
`EnqueueAsync` does a single `INSERT` on the hot path. To avoid a
|
||||
`SELECT COUNT(*)` on every enqueue, the sink keeps an in-memory non-dead-lettered
|
||||
row counter (seeded at startup, kept current by every mutation, and re-synced
|
||||
from storage every 10,000 enqueues to defend against drift). SQLite writer
|
||||
contention is handled via `PRAGMA busy_timeout=5000` + WAL so an enqueue/drain
|
||||
collision waits out the file lock instead of failing fast.
|
||||
|
||||
### Drain worker
|
||||
|
||||
`StartDrainLoop(tickInterval)` starts a **self-rescheduling one-shot
|
||||
`System.Threading.Timer`** (not started automatically — tests drive
|
||||
`DrainOnceAsync` deterministically). Each tick:
|
||||
|
||||
1. Purges aged dead-lettered rows past the retention window.
|
||||
2. Reads up to `batchSize` non-dead-lettered rows in `RowId` order.
|
||||
3. Rows with un-deserializable payloads are dead-lettered immediately (by their
|
||||
own `RowId`) so they can't stall the queue head.
|
||||
4. The remaining batch is handed to `IAlarmHistorianWriter.WriteBatchAsync`, and
|
||||
each outcome is applied in one transaction: `Ack` deletes the row,
|
||||
`PermanentFail` flips its `DeadLettered` flag, `RetryPlease` bumps its attempt
|
||||
count and leaves it queued.
|
||||
5. The timer re-arms its next due-time to `max(tickInterval, currentBackoff)`.
|
||||
|
||||
**Backoff ladder** (applied to the timer's next due-time, so a historian outage
|
||||
genuinely slows the drain cadence): 1s → 2s → 5s → 15s → 60s cap. Any
|
||||
`RetryPlease` outcome — or a writer exception, or a writer cardinality violation
|
||||
(outcome count ≠ event count) — bumps the backoff and sets `DrainState =
|
||||
BackingOff`; a clean batch resets it. The async-void timer callback is fully
|
||||
guarded: a fault is logged and recorded into `GetStatus()` rather than lost as
|
||||
an unobserved task exception.
|
||||
|
||||
### Durability bound (important)
|
||||
|
||||
**The durability guarantee is bounded by `capacity` (default 1,000,000 rows).**
|
||||
When the non-dead-lettered queue reaches capacity, `EnqueueAsync` evicts the
|
||||
oldest non-dead-lettered rows (oldest `RowId` first) to make room, logs a WARN,
|
||||
and increments `HistorianSinkStatus.EvictedCount`. Under a sustained historian
|
||||
outage, accepted alarm events can therefore be dropped before delivery. A
|
||||
non-zero `EvictedCount` is a data-loss signal that requires operator attention —
|
||||
it surfaces silent loss without log scraping.
|
||||
|
||||
### Dead-letter + operator recovery
|
||||
|
||||
`PermanentFail` and corrupt-payload rows are retained in-place with
|
||||
`DeadLettered = 1` for the retention window (default 30 days) so operators can
|
||||
inspect them before the sweeper purges them. `RetryDeadLettered()` is the
|
||||
operator action (from the AdminUI) that clears the dead-letter flag and attempt
|
||||
count on every dead-lettered row, returning them to the regular queue with a
|
||||
fresh backoff.
|
||||
|
||||
---
|
||||
|
||||
## Runtime wiring
|
||||
|
||||
Production routes alarm transitions through the Akka cluster. The
|
||||
`HistorianAdapterActor`
|
||||
([`Runtime/Historian/HistorianAdapterActor.cs`](../src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs))
|
||||
bridges messages from the scripted-alarm actor into the sink's `EnqueueAsync`,
|
||||
fire-and-forget so the actor loop is never blocked on historian reachability.
|
||||
The `WonderwareHistorianClient` is the `IAlarmHistorianWriter` the drain worker
|
||||
delegates to. See [ServiceHosting.md](ServiceHosting.md) for the sidecar setup.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [AlarmTracking.md](AlarmTracking.md) — the three alarm sources and the OPC UA
|
||||
Part 9 surface; which alarms route to this sink.
|
||||
- [DriverLifecycle.md](DriverLifecycle.md) — `IHistorianDataSource` (the
|
||||
historian *read* surface; this page covers the *write* path) and the
|
||||
`WonderwareHistorianClient`.
|
||||
- [ScriptedAlarms.md](ScriptedAlarms.md) — the scripted-alarm engine that emits
|
||||
most events into this sink.
|
||||
- [ServiceHosting.md](ServiceHosting.md) — the optional Wonderware historian
|
||||
sidecar.
|
||||
+24
-21
@@ -13,11 +13,12 @@ historical reference.
|
||||
|----------------------------------|--------------------------|------|
|
||||
| **Galaxy MxAccess (driver-native)** | `GalaxyDriver : IAlarmSource` | gateway → worker → MxAccess alarm sink → `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` → `EventPump` → driver `OnAlarmEvent` → `AlarmConditionService` |
|
||||
| **Galaxy sub-attribute fallback** | `IWritable` writes to `$Alarm*` sub-attributes | gateway data subscription → driver `OnDataChange` → `DriverNodeManager` ConditionSink → `AlarmConditionService` |
|
||||
| **Scripted alarms** | `Phase7EngineComposer` | server-side script evaluator → `Phase7EngineComposer.RouteToHistorianAsync` + `AlarmConditionService` |
|
||||
| **Scripted alarms** | `Phase7Composer` | server-side script evaluator → `ScriptedAlarmActor` transitions → `HistorianAdapterActor` → `IAlarmHistorianSink` |
|
||||
|
||||
All three converge on `AlarmConditionService` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Alarms/AlarmConditionService.cs`),
|
||||
which owns the OPC UA Part 9 state machine and dispatches transitions
|
||||
to the OPC UA condition node managers. Driver-native transitions take
|
||||
All three converge on the alarm-state actor — in v2 the OPC UA Part 9 state
|
||||
machine lives inside `ScriptedAlarmActor`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs`),
|
||||
which dispatches transitions to the OPC UA condition node managers. Driver-native transitions take
|
||||
precedence over sub-attribute synthesis when both arrive for the same
|
||||
condition — the dedup logic prefers the richer driver-native record
|
||||
because it carries the full operator + raise-time + category metadata
|
||||
@@ -103,23 +104,25 @@ calls.
|
||||
Scripted alarms (and any future non-Galaxy `IAlarmSource` like
|
||||
AB CIP ALMD) route to AVEVA Historian via the Wonderware sidecar:
|
||||
|
||||
- `Phase7Composer.ResolveHistorianSink` resolves an
|
||||
`IAlarmHistorianWriter` from either a driver that natively
|
||||
implements it or the DI-registered `WonderwareHistorianClient`
|
||||
(the sidecar IPC client). Driver-provided wins when both are
|
||||
present.
|
||||
- `IAlarmHistorianSink` is the DI-registered intake contract. The
|
||||
default binding is `NullAlarmHistorianSink` (registered in
|
||||
`ServiceCollectionExtensions.AddOtOpcUaRuntime`). Production
|
||||
deployments override it with `SqliteStoreAndForwardSink` wrapping
|
||||
`WonderwareHistorianClient` (the AVEVA Historian sidecar IPC client)
|
||||
— see [ServiceHosting.md](ServiceHosting.md) for the sidecar setup.
|
||||
- `SqliteStoreAndForwardSink` queues each transition to a local
|
||||
SQLite database and drains in the background via the resolved
|
||||
writer. **The durability guarantee is bounded**: the queue capacity
|
||||
defaults to 1,000,000 rows; under a sustained historian outage,
|
||||
older non-dead-lettered rows are evicted (oldest first) to make
|
||||
room for new events. The `HistorianSinkStatus.EvictedCount` counter
|
||||
surfaces lifetime eviction events to the Admin UI
|
||||
`/alarms/historian` diagnostics page so operators can detect silent
|
||||
data loss without log scraping.
|
||||
- Sidecar (PR C.1 + C.2) forwards the events to `aahClientManaged`'s
|
||||
alarm-event write API; the live SDK call site is pinned during
|
||||
PR D.1's deploy-rig validation.
|
||||
SQLite database and drains in the background via an
|
||||
`IAlarmHistorianWriter`. **The durability guarantee is bounded**: the
|
||||
queue capacity defaults to 1,000,000 rows; under a sustained
|
||||
historian outage, older non-dead-lettered rows are evicted (oldest
|
||||
first) to make room for new events. The `HistorianSinkStatus.EvictedCount`
|
||||
counter surfaces lifetime eviction events so operators can detect
|
||||
silent data loss without log scraping.
|
||||
- `HistorianAdapterActor`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs`)
|
||||
bridges Akka cluster messages from `ScriptedAlarmActor` into the
|
||||
sink's `EnqueueAsync`; fire-and-forget so the actor loop is never
|
||||
blocked on historian reachability.
|
||||
|
||||
Galaxy-native alarms with `$Alarm*` extensions reach AVEVA Historian
|
||||
directly via System Platform's `HistorizeToAveva` toggle on the
|
||||
@@ -132,4 +135,4 @@ exclusively for non-Galaxy alarm producers.
|
||||
- v1 archive: [docs/v1/AlarmTracking.md](v1/AlarmTracking.md)
|
||||
- Galaxy driver: [docs/drivers/Galaxy.md](drivers/Galaxy.md)
|
||||
- Phase 7 scripting + alarming: [docs/v2/implementation/phase-7-scripting-and-alarming.md](v2/implementation/phase-7-scripting-and-alarming.md)
|
||||
- Security + ACL: [docs/Security.md](Security.md)
|
||||
- Security + ACL: [docs/security.md](security.md)
|
||||
|
||||
+2
-2
@@ -219,7 +219,7 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
| `Count` | | `AggregateFunction_Count` |
|
||||
| `Start` | `first` | `AggregateFunction_Start` |
|
||||
| `End` | `last` | `AggregateFunction_End` |
|
||||
| `StandardDeviation` | `stddev`, `stdev` | `AggregateFunction_StandardDeviationSample` |
|
||||
| `StandardDeviation` | `stddev`, `stdev` | `AggregateFunction_StandardDeviationPopulation` |
|
||||
|
||||
### alarms
|
||||
|
||||
@@ -261,7 +261,7 @@ Application URI: urn:localhost:OtOpcUa:instance1
|
||||
|
||||
## Testing
|
||||
|
||||
The Client CLI has 52 unit tests covering option parsing, service invocation, output formatting, and cleanup behavior:
|
||||
The Client CLI has 77 unit tests covering option parsing, service invocation, output formatting, and cleanup behavior:
|
||||
|
||||
```bash
|
||||
dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests
|
||||
|
||||
+2
-2
@@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
|
||||
|
||||
### Settings Persistence
|
||||
|
||||
Connection settings are saved to `{LocalAppData}/OtOpcUaClient/settings.json` after each successful connection and on window close. Dev boxes upgrading from a pre-task-#208 build still have the legacy `LmxOpcUaClient/` folder on disk; `ClientStoragePaths` in `Client.Shared` moves it to the canonical path on first launch so existing trusted certs + saved settings persist without operator action. The settings are reloaded on next launch, including:
|
||||
Connection settings are saved to `{LocalAppData}/OtOpcUaClient/settings.json` after each successful connection, on disconnect, and on window close. Dev boxes upgrading from a pre-task-#208 build still have the legacy `LmxOpcUaClient/` folder on disk; `ClientStoragePaths` in `Client.Shared` moves it to the canonical path on first launch so existing trusted certs + saved settings persist without operator action. The settings are reloaded on next launch, including:
|
||||
|
||||
- All connection parameters
|
||||
- Active subscription node IDs (restored after reconnection)
|
||||
@@ -100,7 +100,7 @@ Select a node in the browse tree to auto-read its current value. The tab display
|
||||
- Status code (e.g., `0x00000000 (Good)`)
|
||||
- Source and server timestamps
|
||||
|
||||
To write a value, enter the new value and click Send. The service reads the current value first to determine the target type, then converts and writes.
|
||||
To write a value, enter the new value and click Write. The shared `OpcUaClientService.WriteValueAsync` pre-reads the node's current value to determine its type, then calls `ValueConverter.ConvertValue` to produce a typed value client-side before sending a typed `DataValue` to the server. Type resolution happens in the client, not on the server.
|
||||
|
||||
## Subscriptions Tab
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Configuration Reference
|
||||
|
||||
This is the live configuration reference for the OtOpcUa Host (`src/Server/ZB.MOM.WW.OtOpcUa.Host/`). It enumerates the `appsettings*.json` sections, the bound Options classes, and the `OTOPCUA_*` / sim-endpoint environment variables — every entry grounded in source.
|
||||
|
||||
Two related concerns get their own dedicated pages and are **only summarised + linked** here, not duplicated:
|
||||
|
||||
- **Transport security, OPC UA authentication, LDAP, data-/control-plane authorization** → [`security.md`](security.md)
|
||||
- **Redundancy + the `Cluster` section** → [`Redundancy.md`](Redundancy.md)
|
||||
|
||||
## How configuration is layered
|
||||
|
||||
The Host (`Program.cs`) loads `appsettings.json`, then overlays a **per-role** file chosen from the cluster roles:
|
||||
|
||||
- A single role → `appsettings.{role}.json` (e.g. `appsettings.driver.json`, `appsettings.admin.json`).
|
||||
- Both roles → `appsettings.admin-driver.json` (roles joined with `-`, ordinal-sorted).
|
||||
- `appsettings.{ASPNETCORE_ENVIRONMENT}.json` (e.g. `appsettings.Development.json`) is layered on by the host builder.
|
||||
|
||||
All role overlays are **optional** — the base `appsettings.json` plus the Options-class C# defaults are enough to boot. The roles themselves come from the `OTOPCUA_ROLES` env var (see [`ServiceHosting.md`](ServiceHosting.md) and the table below).
|
||||
|
||||
The checked-in `appsettings*.json` files are deliberately thin: they carry only `Serilog` and the `Security:Ldap` overlay. Everything else (`OpcUa`, `Cluster`, `ConnectionStrings`/`ConfigDb`) binds from the Options-class defaults documented below unless an operator adds the section explicitly or supplies the corresponding environment variable.
|
||||
|
||||
---
|
||||
|
||||
## `appsettings` sections
|
||||
|
||||
### `Serilog`
|
||||
|
||||
- **Purpose:** logging. Console + rolling daily file sink, layered with the shared `ZB.MOM.WW.Telemetry` enrichers (`AddZbSerilog` in `Program.cs`).
|
||||
- **Where bound:** `builder.AddZbSerilog(...)` reads `Serilog` from configuration (`ReadFrom.Configuration`).
|
||||
- **Checked-in shape** (`appsettings.json`): `Using` = `[ "Serilog.Sinks.Console", "Serilog.Sinks.File" ]`, `WriteTo` = a `Console` sink and a `File` sink (`path: logs/otopcua-.log`, `rollingInterval: Day`). Role overlays add `MinimumLevel` / `Override` blocks (e.g. `Opc.Ua: Debug`, `Akka: Information`).
|
||||
|
||||
### `OpcUa`
|
||||
|
||||
- **Purpose:** the OPC UA server endpoint identity, listening port, PKI, transport-security profiles, and redundancy peer advertising.
|
||||
- **Options class:** `OpcUaApplicationHostOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`.
|
||||
- **Bound by:** `AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(config, "OpcUa")` in `Program.cs` (driver-role only). Validated fail-fast at startup by `OpcUaApplicationHostOptionsValidator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs`).
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `ApplicationName` | string | `OtOpcUa` | Server application name. Required (validated). |
|
||||
| `ApplicationUri` | string | `urn:OtOpcUa` | Server application URI. Must be unique per redundancy node. Required. |
|
||||
| `ProductUri` | string | `https://zb.com/otopcua` | Product URI. Not validated. |
|
||||
| `OpcUaPort` | int | `4840` | Binary endpoint listen port. Validated as a port. |
|
||||
| `PublicHostname` | string | `0.0.0.0` | Hostname/IP advertised in endpoint descriptions. Required. |
|
||||
| `ApplicationConfigPath` | string? | `null` | Optional path to an application config XML; loaded instead of building from defaults. |
|
||||
| `PkiStoreRoot` | string | `pki` | Root of the PKI hierarchy (`own`/`issuer`/`trusted`/`rejected` substores created under it). Required. See [`security.md`](security.md). |
|
||||
| `EnabledSecurityProfiles` | list of `OpcUaSecurityProfile` | `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` | Transport-security profiles, one endpoint per entry. Must contain ≥1. Profile detail in [`security.md`](security.md). |
|
||||
| `AutoAcceptUntrustedClientCertificates` | bool | `false` | Auto-trust unknown client certs on first connect (dev convenience). Not validated. See [`security.md`](security.md). |
|
||||
| `PeerApplicationUris` | list of string | `[]` (empty) | Partner node `ApplicationUri`s published in `Server.ServerArray` for redundancy discovery. See [`Redundancy.md`](Redundancy.md). |
|
||||
|
||||
> **Transport security profiles** (the values in `EnabledSecurityProfiles` — `None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`) and the PKI trust flow are documented in full in [`security.md`](security.md). This page does not duplicate them.
|
||||
|
||||
### `Security`
|
||||
|
||||
- **Purpose:** Admin-UI and OPC UA authentication. Three subsections, each its own Options class:
|
||||
|
||||
| Subsection | Options class (`SectionName`) | Purpose |
|
||||
|---|---|---|
|
||||
| `Security:Ldap` | `LdapOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs` | LDAP bind for Admin cookie login + OPC UA UserName tokens. Bound by `AddValidatedOptions<LdapOptions, LdapOptionsValidator>` in `Program.cs`. |
|
||||
| `Security:Jwt` | `JwtOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs` | Signing config for the JWT minted at `/auth/token` for **external** consumers (OPC UA clients / automation). |
|
||||
| `Security:Cookie` | `OtOpcUaCookieOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs` | The Admin-UI auth cookie (`AddOtOpcUaAuth` copies these onto `CookieAuthenticationOptions`). |
|
||||
|
||||
**`Security:Ldap` — see [`security.md`](security.md) for the full field-by-field reference and bind-flow.** The checked-in role overlays set only `DevStubMode` and `Transport`; the remaining `LdapOptions` fields (`Enabled`, `Server`, `Port`, `AllowInsecure`, `SearchBase`, `ServiceAccountDn`, `ServiceAccountPassword`, `GroupAttribute`, `DisplayNameAttribute`, `UserNameAttribute`, `GroupToRole`) are covered there.
|
||||
|
||||
**`Security:Jwt`** key fields (`JwtOptions`):
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `SigningKey` | string | `""` | HS256 signing key; must be ≥32 bytes UTF-8. Set from your secret store — never commit a value. |
|
||||
| `Issuer` | string | `otopcua` | JWT issuer. |
|
||||
| `Audience` | string | `otopcua` | JWT audience. |
|
||||
| `ExpiryMinutes` | int | `15` | Token lifetime. |
|
||||
|
||||
**`Security:Cookie`** key fields (`OtOpcUaCookieOptions`):
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `Name` | string | `ZB.MOM.WW.OtOpcUa.Auth` | Auth cookie name. Changing it invalidates existing sessions on next deploy. |
|
||||
| `ExpiryMinutes` | int | `30` | Idle sliding-window length. |
|
||||
| `RequireHttpsCookie` | bool | `true` | `SecurePolicy = Always`. Set `false` only for plain-HTTP local dev (emits a startup Warning). |
|
||||
|
||||
> Authentication, data-plane authorization (`NodeAcl` / `PermissionTrie`), and control-plane Admin roles are all in [`security.md`](security.md).
|
||||
|
||||
### `Cluster`
|
||||
|
||||
- **Purpose:** Akka.NET cluster identity, transport, and roles — the backbone of redundancy.
|
||||
- **Options class:** `AkkaClusterOptions` (`SectionName = "Cluster"`) — `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaClusterOptions.cs`. Bound by `AddOtOpcUaCluster(config)` in `Program.cs`.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `SystemName` | string | `otopcua` | Akka actor-system name. |
|
||||
| `Hostname` | string | `0.0.0.0` | Bind hostname. |
|
||||
| `Port` | int | `4053` | Cluster transport port. |
|
||||
| `PublicHostname` | string | `127.0.0.1` | Hostname advertised in cluster gossip; must be reachable by peers. |
|
||||
| `SeedNodes` | string[] | `[]` | Seed nodes for bootstrapping. |
|
||||
| `Roles` | string[] | `[]` | Cluster roles for this node. When empty, falls back to `OTOPCUA_ROLES`. Allowed values: `admin`, `driver`, `dev`. |
|
||||
|
||||
> The full redundancy model (ServiceLevel tiers, split-brain, peer discovery) is in [`Redundancy.md`](Redundancy.md). The OPC UA peer-URI advertising lives in the `OpcUa:PeerApplicationUris` key above.
|
||||
|
||||
### `ConnectionStrings` → `ConfigDb`
|
||||
|
||||
- **Purpose:** the central Config DB connection string. **Required for every role** — `Program.cs` calls `AddOtOpcUaConfigDb` unconditionally.
|
||||
- **Bound by:** `AddOtOpcUaConfigDb(config)` (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs`). The connection-string name constant is `ConnectionStringName = "ConfigDb"`, read via `configuration.GetConnectionString("ConfigDb")`. If absent, startup throws with a message pointing to either `appsettings.json` or the `OTOPCUA_CONFIG_CONNECTION` env var.
|
||||
- **Shape:** standard `ConnectionStrings:ConfigDb` SQL Server connection string. There is no checked-in default in the thin `appsettings*.json` — supply it per environment.
|
||||
|
||||
The Config DB itself (the EF Core `OtOpcUaConfigDbContext`, entities, draft/publish generations, `NodeAcl`, `LdapGroupRoleMapping`, migrations) is the durable home for the fleet's drivers, UNS hierarchy, ACLs, and audit log. For the **full schema** see [`docs/v2/config-db-schema.md`](v2/config-db-schema.md). This page does not duplicate it.
|
||||
|
||||
### Galaxy / MxAccess driver config (`DriverConfig` JSON, not `appsettings`)
|
||||
|
||||
The Galaxy/MxAccess connection settings are **not an `appsettings` section.** They are driver-instance options stored in the `DriverConfig` JSON column of the Config DB (edited via the Admin UI), bound to `GalaxyDriverOptions` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs`, namespace `...Driver.Galaxy.Config`). It decomposes into nested records:
|
||||
|
||||
| Record | Key fields (default) | Meaning |
|
||||
|---|---|---|
|
||||
| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `env:NAME` / `file:PATH` / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `env:`/`file:` in production. Never store a cleartext key. |
|
||||
| `GalaxyMxAccessOptions` (`MxAccess`) | `ClientName`; `PublishingIntervalMs` (`1000`); `WriteUserId` (`0` = anonymous); `EventPumpChannelCapacity` (`50000`) | MXAccess client identity + tuning. `ClientName` **must be unique per OtOpcUa instance** (redundancy pairs enforce this). |
|
||||
| `GalaxyRepositoryOptions` (`Repository`) | `DiscoverPageSize` (`5000`); `WatchDeployEvents` (`true`) | Galaxy Repository browse paging + deploy-event watching. |
|
||||
| `GalaxyReconnectOptions` (`Reconnect`) | `InitialBackoffMs` (`500`); `MaxBackoffMs` (`30000`); `ReplayOnSessionLost` (`true`) | In-driver reconnect-supervisor backoff. |
|
||||
| (top-level) | `ProbeTimeoutSeconds` (`30`, range 1–60) | AdminUI Test-Connect probe timeout. |
|
||||
|
||||
> The `OTOPCUA_GALAXY_*` environment variables that v1's in-process `Galaxy.Host` consumed **no longer live in this repo** — they moved into the separately-installed mxaccessgw gateway's own config (see the v1 archive pointer in `docs/README.md` and the Galaxy overview at [`docs/drivers/Galaxy.md`](drivers/Galaxy.md)). The only Galaxy connection secret this repo touches is the gateway API key via `ApiKeySecretRef` above.
|
||||
|
||||
### Historian config (env-driven sidecar)
|
||||
|
||||
The Wonderware Historian runs as a supervised sidecar process whose configuration arrives **entirely through environment variables**, not an `appsettings` section. The sidecar entry point (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs`) reads them at spawn time. See the `OTOPCUA_HISTORIAN_*` rows in the environment-variable table below. The in-process client-side options POCO is `WonderwareHistorianClientOptions` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs`): `PipeName`, `SharedSecret`, `PeerName` (`OtOpcUa`), `ConnectTimeout` (default 10s), `CallTimeout` (default 30s), `ProbeTimeoutSeconds` (`15`).
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
All names are read in this repo's source via `Environment.GetEnvironmentVariable(...)` unless noted otherwise. Defaults shown are the in-source fallbacks.
|
||||
|
||||
### Host / cluster / Config DB
|
||||
|
||||
| Variable | Read by | Effect / default |
|
||||
|---|---|---|
|
||||
| `OTOPCUA_ROLES` | `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (`RoleParser.Parse`) | Comma-separated cluster roles for the node (`admin`, `driver`, `dev`). Drives the conditional wiring and the per-role appsettings overlay. Used when `Cluster:Roles` is empty. |
|
||||
| `OTOPCUA_CONFIG_CONNECTION` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs` (design-time / `dotnet ef` only) | Read at **design time** by `DesignTimeDbContextFactory.cs` for `dotnet ef` migrations. At **runtime** the server resolves the connection string from `ConnectionStrings:ConfigDb` (env form: `ConnectionStrings__ConfigDb`) via `configuration.GetConnectionString("ConfigDb")` in `ServiceCollectionExtensions.cs` — `OTOPCUA_CONFIG_CONNECTION` appears there only as a hint in an error message, not via `GetEnvironmentVariable`. No credential is embedded in source. |
|
||||
| `OTOPCUA_ALLOWED_SID` | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs` | SID of the server principal allowed to connect to the historian sidecar's named pipe (passed by the supervisor at spawn). Required — sidecar throws if unset. |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ASP.NET host builder (framework) | Selects `appsettings.{Environment}.json` (e.g. `Development`). |
|
||||
|
||||
### Historian sidecar (`OTOPCUA_HISTORIAN_*`)
|
||||
|
||||
All read in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs`.
|
||||
|
||||
| Variable | Effect / default |
|
||||
|---|---|
|
||||
| `OTOPCUA_HISTORIAN_PIPE` | Named-pipe name the sidecar listens on. Required (throws if unset). |
|
||||
| `OTOPCUA_HISTORIAN_SECRET` | Per-process shared secret verified in the pipe Hello frame. Required (throws if unset). |
|
||||
| `OTOPCUA_HISTORIAN_ENABLED` | `true` opens the real Wonderware SDK connection; anything else → pipe-only mode (smoke/IPC tests). Default: not-true → pipe-only. |
|
||||
| `OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED` | `false` disables the alarm-event writer (sidecar rejects `WriteAlarmEvents`). Default `true` (when `ENABLED=true`). |
|
||||
| `OTOPCUA_HISTORIAN_INTEGRATED` | `false` → SQL auth (use `USER`/`PASS`); any other value → integrated security. Default: integrated. |
|
||||
| `OTOPCUA_HISTORIAN_SERVER` | Historian server hostname. Default `localhost`. |
|
||||
| `OTOPCUA_HISTORIAN_SERVERS` | Comma-separated multi-node server list (overrides single `SERVER` when set). |
|
||||
| `OTOPCUA_HISTORIAN_PORT` | Historian port. Default `32568`. |
|
||||
| `OTOPCUA_HISTORIAN_USER` | SQL username (when not integrated). |
|
||||
| `OTOPCUA_HISTORIAN_PASS` | SQL password (when not integrated). Never commit a value. |
|
||||
| `OTOPCUA_HISTORIAN_TIMEOUT_SEC` | Command timeout (seconds). Default `30`. |
|
||||
| `OTOPCUA_HISTORIAN_MAX_VALUES` | Max values returned per read. Default `10000`. |
|
||||
| `OTOPCUA_HISTORIAN_COOLDOWN_SEC` | Failure cooldown (seconds). Default `60`. |
|
||||
|
||||
### Driver integration-test / fixture sim endpoints
|
||||
|
||||
These are consumed by the driver **integration-test fixtures** (under `tests/Drivers/...IntegrationTests/`), not by the production server. Each overrides the simulator endpoint a fixture TCP-probes; defaults point at the shared Docker host `10.100.0.35` (see `CLAUDE.md` Docker Workflow).
|
||||
|
||||
| Variable | Read by (fixture) | Default |
|
||||
|---|---|---|
|
||||
| `MODBUS_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs` | `10.100.0.35:5020` |
|
||||
| `AB_SERVER_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs` | `10.100.0.35:44818` |
|
||||
| `S7_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs` | `10.100.0.35:1102` (non-privileged; not S7-standard 102) |
|
||||
| `OPCUA_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs` | `opc.tcp://10.100.0.35:50000` |
|
||||
| `OTOPCUA_FOCAS_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs` | `localhost:8193` |
|
||||
|
||||
> Additional harness/parity/soak env vars (`OTOPCUA_FOCAS_*`, `OTOPCUA_PARITY_*`, `OTOPCUA_SOAK_*`, `OTOPCUA_HARNESS_USE_SQL`) exist only in the test/parity/soak harnesses, not in production source, and are out of scope for this reference.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [`security.md`](security.md) — transport security, OPC UA authentication, LDAP (`Security:Ldap`), data-plane ACLs, control-plane roles.
|
||||
- [`Redundancy.md`](Redundancy.md) — the `Cluster` section in the context of warm/hot redundancy, ServiceLevel, peer discovery.
|
||||
- [`ServiceHosting.md`](ServiceHosting.md) — role-based host wiring and `OTOPCUA_ROLES`.
|
||||
- [`docs/drivers/Galaxy.md`](drivers/Galaxy.md) — Galaxy/MxAccess driver overview.
|
||||
- [`docs/v2/config-db-schema.md`](v2/config-db-schema.md) — the full Config DB schema.
|
||||
@@ -4,7 +4,7 @@ Ad-hoc probe / read / write / subscribe tool for SLC 500 / MicroLogix 1100 /
|
||||
MicroLogix 1400 / PLC-5 devices, talking to the **same** `AbLegacyDriver` the
|
||||
OtOpcUa server uses (libplctag PCCC back-end).
|
||||
|
||||
Third of four driver test-client CLIs. Shares `Driver.Cli.Common` with the
|
||||
Third of six driver test-client CLIs. Shares `Driver.Cli.Common` with the
|
||||
others.
|
||||
|
||||
## Build + run
|
||||
|
||||
@@ -5,8 +5,8 @@ through the **same** `ModbusDriver` the OtOpcUa server uses. Mirrors the v1
|
||||
OPC UA `otopcua-cli` shape so the muscle memory carries over: drop to a shell,
|
||||
point at a PLC, watch registers move.
|
||||
|
||||
First of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||
TwinCAT). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library
|
||||
First of six driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||
TwinCAT → FOCAS). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library
|
||||
so each downstream CLI inherits verbose/log wiring + snapshot formatting
|
||||
without copy-paste.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Ad-hoc probe / read / write / subscribe tool for Siemens S7-300 / S7-400 /
|
||||
S7-1200 / S7-1500 (and compatible soft-PLCs) over S7comm / ISO-on-TCP port 102.
|
||||
Uses the **same** `S7Driver` the OtOpcUa server does (S7.Net under the hood).
|
||||
|
||||
Fourth of four driver test-client CLIs.
|
||||
Fourth of six driver test-client CLIs.
|
||||
|
||||
## Build + run
|
||||
|
||||
@@ -58,6 +58,12 @@ otopcua-s7-cli probe -h 192.168.1.31 -c S7300 --slot 2 -a DB1.DBW0
|
||||
|
||||
### `read`
|
||||
|
||||
Supported types: `Bool`, `Byte`, `Int16`, `UInt16`, `Int32`, `UInt32`, `Float32`.
|
||||
`Int64`, `UInt64`, `Float64`, `String`, and `DateTime` are defined in `S7DataType` but
|
||||
**not yet implemented** — the driver rejects them at initialisation and any read or write
|
||||
returns `BadNotSupported`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` — `UnimplementedDataTypes` set).
|
||||
|
||||
```powershell
|
||||
# DB word
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBW0 -t Int16
|
||||
@@ -67,9 +73,6 @@ otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBD4 -t Float32
|
||||
|
||||
# Merker bit
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
|
||||
|
||||
# 80-char S7 string
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
|
||||
```
|
||||
|
||||
### `write`
|
||||
|
||||
@@ -5,7 +5,7 @@ TwinCAT 3 runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa
|
||||
server does (`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by
|
||||
default; `--poll-only` falls back to the shared `PollGroupEngine`.
|
||||
|
||||
Fifth (final) of the driver test-client CLIs.
|
||||
Fifth of six driver test-client CLIs.
|
||||
|
||||
## Build + run
|
||||
|
||||
@@ -55,7 +55,7 @@ Per-command flags:
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-s` / `--symbol` | **required** | Symbol path to probe (e.g. `MAIN.bRunning`) |
|
||||
| `--type` | `DInt` | Declared data type — see the [Data types](#data-types) list |
|
||||
| `-t` / `--type` | `DInt` | Declared data type — see the [Data types](#data-types) list |
|
||||
|
||||
```powershell
|
||||
# Local TwinCAT 3, probe a canonical global
|
||||
|
||||
+9
-5
@@ -35,6 +35,10 @@ Every driver CLI exposes the same four verbs:
|
||||
push where available (TwinCAT ADS notifications) and falls back to polling
|
||||
(`PollGroupEngine`) where the protocol has no push (Modbus, AB, S7, FOCAS).
|
||||
|
||||
The TwinCAT CLI adds a fifth verb, **`browse`** — it walks the controller's
|
||||
symbol table via the driver's `DiscoverAsync` path and prints every symbol the
|
||||
atomic-type mapper recognises. No other driver CLI ships `browse`.
|
||||
|
||||
## Shared infrastructure
|
||||
|
||||
All six CLIs depend on `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/`:
|
||||
@@ -88,8 +92,8 @@ their flag values to the already-shipped driver.
|
||||
## Tracking
|
||||
|
||||
Tasks #249 / #250 / #251 shipped the original five. The FOCAS CLI followed
|
||||
alongside the Tier-C isolation work on task #220 — no CLI-level test
|
||||
project (hardware-gated). 122 unit tests cumulative across the first five
|
||||
(16 shared-lib + 106 CLI-specific) — run
|
||||
`dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests` +
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.*.Cli.Tests` to re-verify.
|
||||
alongside the Tier-C isolation work on task #220. Every CLI — FOCAS included —
|
||||
ships its own unit-test project under `tests/Drivers/Cli/`, alongside the shared
|
||||
`tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests`. Re-verify with
|
||||
`dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests` and
|
||||
each per-family `tests/Drivers/Cli/...Cli.Tests` project.
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# Driver Lifecycle & Server Infrastructure Contracts
|
||||
|
||||
Reference for the server-side infrastructure interfaces that surround a
|
||||
driver but are **not** driver *capabilities* (read/write/subscribe/etc.,
|
||||
documented in [ReadWriteOperations.md](ReadWriteOperations.md) and the
|
||||
per-driver pages). These contracts live in
|
||||
[`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/)
|
||||
so they carry no behavior — concrete implementations live in the driver
|
||||
projects, the Runtime, and the ControlPlane. Each subsection below gives the
|
||||
purpose, the key members, and where it is implemented/used.
|
||||
|
||||
The capability interfaces a driver opts into (`IReadable`, `IWritable`,
|
||||
`ITagDiscovery`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`,
|
||||
`IHostConnectivityProbe`, `IPerCallHostResolver`, `IRediscoverable`) are
|
||||
covered elsewhere and discovered by the server via `is`-checks on the
|
||||
`IDriver` instance. The interfaces here are the *plumbing* the server uses to
|
||||
**create**, **probe**, **supervise**, **report on**, and **configure** those
|
||||
drivers, plus the server-side historian read surface.
|
||||
|
||||
---
|
||||
|
||||
## IDriverFactory — creating drivers from config rows
|
||||
|
||||
[`Core.Abstractions/IDriverFactory.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs)
|
||||
|
||||
Abstraction over the process-wide driver registry. The Runtime consumes this
|
||||
instead of the concrete registry so the Runtime project does not pull in
|
||||
`ZB.MOM.WW.OtOpcUa.Core` (which would drag in Polly + driver hosting).
|
||||
|
||||
Members:
|
||||
|
||||
- `IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)`
|
||||
— returns a new driver for the given type, or `null` when no factory is
|
||||
registered for that type (missing assembly, typo). The `DriverHostActor`
|
||||
logs and skips the row rather than failing the whole apply.
|
||||
- `IReadOnlyCollection<string> SupportedTypes` — driver-type names this
|
||||
factory can materialise; mostly for diagnostics and logs.
|
||||
|
||||
Implementations:
|
||||
|
||||
- `NullDriverFactory` (same file) returns `null` from every `TryCreate` and
|
||||
exposes zero supported types. Bound when no concrete driver assemblies have
|
||||
been registered (Mac dev path, smoke tests); the deployment becomes a no-op.
|
||||
- `DriverFactoryRegistry`
|
||||
([`Core/Hosting/DriverFactoryRegistry.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs))
|
||||
is the real process-singleton registry keyed by `DriverInstance.DriverType`
|
||||
(case-insensitive). Each driver project ships a `Register(...)` extension;
|
||||
`Register` records the factory **and** the driver's stability
|
||||
[`DriverTier`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTier.cs)
|
||||
(defaults to Tier A). Registering the same type twice throws.
|
||||
- `DriverFactoryRegistryAdapter`
|
||||
([`Core/Hosting/DriverFactoryRegistryAdapter.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs))
|
||||
bridges the registry to the `IDriverFactory` abstraction.
|
||||
|
||||
Wiring: `DriverFactoryBootstrap.AddOtOpcUaDriverFactories`
|
||||
([`Host/Drivers/DriverFactoryBootstrap.cs`](../src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs))
|
||||
registers the singleton registry, runs every driver assembly's `Register`
|
||||
extension, then binds `IDriverFactory` to the adapter. It must run **before**
|
||||
`AddAkka` so the Runtime can resolve `IDriverFactory` when spawning the
|
||||
`DriverHostActor`
|
||||
([`Runtime/Drivers/DriverHostActor.cs`](../src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs)).
|
||||
The registry is skipped on admin-only nodes (they never run drivers); the
|
||||
probe set is the exception — see [IDriverProbe](#idriverprobe--test-connect).
|
||||
|
||||
---
|
||||
|
||||
## IDriverProbe — Test Connect
|
||||
|
||||
[`Core.Abstractions/IDriverProbe.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs)
|
||||
|
||||
A cheap test-connect probe for one driver type, backing the AdminUI **Test
|
||||
Connect** button. An implementation deserializes a driver-config JSON, attempts
|
||||
a cheap connection (TCP open, OPC UA session, gRPC ping — whatever the driver's
|
||||
native protocol supports), and reports success/failure with latency. **Probes
|
||||
must not mutate persistent state**: the AdminUI invokes them against the
|
||||
transient config in the typed form, not against the persisted `DriverInstance`
|
||||
row.
|
||||
|
||||
Members:
|
||||
|
||||
- `string DriverType { get; }` — the `DriverInstance.DriverType` string this
|
||||
probe handles; used for DI lookup.
|
||||
- `Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)`
|
||||
— never throws on connection failure; returns a result with `Ok = false`
|
||||
and a message instead.
|
||||
- `DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency)` — outcome
|
||||
record (`Message` is `null` on success; `Latency` is `null` on failure).
|
||||
|
||||
Implementations: every driver ships a `*DriverProbe` in its driver project
|
||||
(e.g.
|
||||
[`Driver.Modbus/ModbusDriverProbe.cs`](../src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs)
|
||||
does a bare socket open/close), plus the Wonderware historian's
|
||||
`WonderwareHistorianDriverProbe`.
|
||||
|
||||
Flow: the AdminUI's `AdminProbeService`
|
||||
([`AdminUI/Clients/AdminProbeService.cs`](../src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs))
|
||||
dispatches a `TestDriverConnect` message through `IAdminOperationsClient` to the
|
||||
cluster-singleton `AdminOperationsActor`
|
||||
([`ControlPlane/AdminOperations/AdminOperationsActor.cs`](../src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs)),
|
||||
which holds the probes keyed by `DriverType` and invokes the matching one
|
||||
(timeout clamped to `[1, 60]` seconds). Because the admin singleton is
|
||||
admin-pinned, the probe set must be registered on admin nodes too — `Program.cs`
|
||||
calls `AddOtOpcUaDriverProbes` in the `hasAdmin` block, and
|
||||
`AddOtOpcUaDriverFactories` registers it for fused admin+driver nodes.
|
||||
|
||||
---
|
||||
|
||||
## IDriverSupervisor — Tier C out-of-process recycle
|
||||
|
||||
[`Core.Abstractions/IDriverSupervisor.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverSupervisor.cs)
|
||||
|
||||
The process-level supervisor contract a **Tier C** (out-of-process) driver's
|
||||
topology provides. Its concern is restarting the out-of-process Host when a
|
||||
hard fault is detected (memory breach, wedge, scheduled recycle window). Tier
|
||||
A/B drivers run in-process and do **not** have a supervisor — recycling them
|
||||
would kill every OPC UA session and every co-hosted driver. The Core.Stability
|
||||
layer only invokes this interface after asserting the tier.
|
||||
|
||||
Members:
|
||||
|
||||
- `string DriverInstanceId { get; }` — the driver instance this supervisor
|
||||
governs.
|
||||
- `Task RecycleAsync(string reason, CancellationToken cancellationToken)` —
|
||||
request a terminate+restart of the Host process; implementations are
|
||||
expected to be idempotent under repeat calls during an in-flight recycle.
|
||||
|
||||
Callers (both in
|
||||
[`Core/Stability/`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/)):
|
||||
|
||||
- `ScheduledRecycleScheduler`
|
||||
([`Core/Stability/ScheduledRecycleScheduler.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/ScheduledRecycleScheduler.cs))
|
||||
— opt-in periodic recycle. A `TickAsync` method advanced by the caller's
|
||||
ambient scheduler decides whether the configured interval has elapsed and, if
|
||||
so, drives `RecycleAsync`. Its constructor throws unless the tier is C, making
|
||||
in-process misuse structurally impossible.
|
||||
- `MemoryRecycle`
|
||||
([`Core/Stability/MemoryRecycle.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryRecycle.cs))
|
||||
— on a memory hard-breach, calls `RecycleAsync` (when a supervisor is wired).
|
||||
|
||||
---
|
||||
|
||||
## IDriverHealthPublisher — health pub/sub sink
|
||||
|
||||
[`Core.Abstractions/IDriverHealthPublisher.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs)
|
||||
|
||||
A sink for driver-health state-change notifications. Implementations must be
|
||||
non-blocking and safe to call from any thread.
|
||||
|
||||
Member:
|
||||
|
||||
- `void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)`
|
||||
|
||||
Implementations:
|
||||
|
||||
- `NullDriverHealthPublisher` (same file) is the drop-in no-op for tests and
|
||||
dev-stub paths. A `DriverInstanceActor` defaults to it when no publisher is
|
||||
supplied.
|
||||
- `AkkaDriverHealthPublisher`
|
||||
([`Runtime/Drivers/AkkaDriverHealthPublisher.cs`](../src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs))
|
||||
is the production binding: it forwards each transition as a
|
||||
`DriverHealthChanged` message onto the cluster-wide `driver-health`
|
||||
Akka DistributedPubSub topic.
|
||||
|
||||
Producer: `DriverInstanceActor`
|
||||
([`Runtime/Drivers/DriverInstanceActor.cs`](../src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs))
|
||||
calls `Publish` when a driver's health transitions. The published snapshot is
|
||||
consumed AdminUI-side and surfaced through the driver-status panel (read
|
||||
in-process by the AdminUI bridge rather than dialing its own hub).
|
||||
|
||||
---
|
||||
|
||||
## IDriverConfigEditor — custom AdminUI config editor (plug-point)
|
||||
|
||||
[`Core.Abstractions/IDriverConfigEditor.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverConfigEditor.cs)
|
||||
|
||||
An **optional** plug-point a driver can implement to provide a custom AdminUI
|
||||
editor for its `DriverConfig` JSON. Drivers that don't implement it fall back to
|
||||
the generic JSON editor with schema-driven validation. This is the contract
|
||||
between the driver and the Admin Blazor app; the Admin app discovers
|
||||
implementations and slots them into the Driver Detail screen.
|
||||
|
||||
Members:
|
||||
|
||||
- `string DriverType { get; }` — the driver type this editor handles.
|
||||
- `Type EditorComponentType { get; }` — the Razor component type that renders
|
||||
the editor (returned as `Type` so `Core.Abstractions` needs no Blazor
|
||||
reference).
|
||||
|
||||
Status: this is a forward-looking plug-point. No driver ships a concrete
|
||||
`IDriverConfigEditor` today — every driver uses the generic JSON editor — so
|
||||
the interface currently has the contract defined but no implementations.
|
||||
|
||||
---
|
||||
|
||||
## IHistorianDataSource — server-side historian read surface
|
||||
|
||||
[`Core.Abstractions/Historian/IHistorianDataSource.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs)
|
||||
|
||||
The server-side historian read surface. Registered with the server's history
|
||||
router and resolved **per OPC UA namespace**, independent of any driver's
|
||||
lifecycle. This is distinct from the driver capability `IHistoryProvider`:
|
||||
|
||||
- `IHistoryProvider` is a *driver capability* — the server dispatches to it via
|
||||
the driver instance.
|
||||
- `IHistorianDataSource` is a *server registration* — the server resolves it by
|
||||
namespace and calls it directly, so one historian (e.g. Wonderware) can serve
|
||||
many drivers' nodes, and drivers can restart without dropping history
|
||||
availability.
|
||||
|
||||
The interface is `: IDisposable` and declares the full read surface as
|
||||
**required** members (unlike `IHistoryProvider`, where at-time/event reads are
|
||||
optional default-impl methods so legacy drivers can stay raw-only):
|
||||
|
||||
- `ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, ct)` — raw
|
||||
historical samples over a time range.
|
||||
- `ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, ct)`
|
||||
— interval-bucketed aggregates (average/min/max/count); an empty bucket
|
||||
returns a `BadNoData` sample.
|
||||
- `ReadAtTimeAsync(fullReference, timestampsUtc, ct)` — one sample per requested
|
||||
timestamp (OPC UA HistoryReadAtTime); the returned list matches the requested
|
||||
length and order, gaps as Bad-quality snapshots.
|
||||
- `ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct)` — historical
|
||||
alarm/event records (OPC UA HistoryReadEvents); `sourceName` is `null` to
|
||||
return all sources. `maxEvents` is a signed `int` so a non-positive value is a
|
||||
"use the backend's default cap" sentinel.
|
||||
- `GetHealthSnapshot()` — point-in-time health snapshot for diagnostics and
|
||||
dashboards; pure observation, never blocks on backend I/O.
|
||||
|
||||
All values use the shared `DataValueSnapshot` / `HistoricalEvent` shapes;
|
||||
backend-specific quality/type encodings are translated to OPC UA `StatusCode`
|
||||
uints inside the data source.
|
||||
|
||||
Implementations:
|
||||
|
||||
- `WonderwareHistorianClient`
|
||||
([`Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs`](../src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs))
|
||||
— the .NET 10 client that talks to the Wonderware historian sidecar over a
|
||||
named pipe. It implements both `IHistorianDataSource` (read paths) and
|
||||
`IAlarmHistorianWriter` (the alarm-event drain target; see
|
||||
[AlarmHistorian.md](AlarmHistorian.md)).
|
||||
- `HistorianDataSource`
|
||||
([`Driver.Historian.Wonderware/Backend/HistorianDataSource.cs`](../src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianDataSource.cs))
|
||||
— the in-process backend implementation behind the sidecar.
|
||||
|
||||
The optional Wonderware historian sidecar setup is described in
|
||||
[ServiceHosting.md](ServiceHosting.md).
|
||||
|
||||
---
|
||||
|
||||
## Commons — shared cross-cutting primitives
|
||||
|
||||
[`src/Core/ZB.MOM.WW.OtOpcUa.Commons/`](../src/Core/ZB.MOM.WW.OtOpcUa.Commons/)
|
||||
|
||||
`ZB.MOM.WW.OtOpcUa.Commons` is the low-level shared library that the Runtime,
|
||||
ControlPlane, AdminUI, and OPC UA server projects all reference. It holds
|
||||
cross-cutting primitives with no driver- or host-specific behavior, so the
|
||||
heavier projects can share message contracts and value types without taking a
|
||||
dependency on each other. It references only `Akka` and the internal
|
||||
`ZB.MOM.WW.Audit` package.
|
||||
|
||||
Folders:
|
||||
|
||||
- **`Messages/`** — Akka message contracts grouped by concern (`Admin`,
|
||||
`Alerts`, `Deploy`, `Drivers`, `Fleet`, `Logging`, `Redundancy`). These are
|
||||
the wire/inter-actor messages — e.g. `Messages/Admin/TestDriverConnect.cs`
|
||||
(Test Connect request, see [IDriverProbe](#idriverprobe--test-connect)) and
|
||||
`Messages/Drivers/DriverHealthChanged.cs` (the driver-health pub/sub payload,
|
||||
see [IDriverHealthPublisher](#idriverhealthpublisher--health-pubsub-sink)).
|
||||
- **`Interfaces/`** — cluster-facing client contracts such as
|
||||
`IAdminOperationsClient`, `IClusterRoleInfo`, and `IFleetDiagnosticsClient`.
|
||||
- **`Types/`** — strongly-typed identifier value types: `CorrelationId`,
|
||||
`DeploymentId`, `ExecutionId`, `NodeId`, `RevisionHash`.
|
||||
- **`Browsing/`** — live-browse abstractions (`BrowseNode`, `IBrowseSession`,
|
||||
`IDriverBrowser`) backing the AdminUI address pickers.
|
||||
- **`Engines/`** — evaluator seams (`IScriptedAlarmEvaluator`,
|
||||
`IVirtualTagEvaluator`, `IAlarmActorStateStore`) consumed by the
|
||||
[VirtualTags](VirtualTags.md) / [ScriptedAlarms](ScriptedAlarms.md) engines.
|
||||
- **`OpcUa/`** — deferred-publish seams (`IOpcUaAddressSpaceSink`,
|
||||
`IServiceLevelPublisher` and their `Deferred*` no-op stand-ins) so address-space
|
||||
and [ServiceLevel](Redundancy.md) writes can be wired late.
|
||||
- **`Observability/`** — `OtOpcUaTelemetry` (the shared ActivitySource/metrics
|
||||
surface).
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [ReadWriteOperations.md](ReadWriteOperations.md) — the driver *capability*
|
||||
interfaces (read/write/subscribe) and resilience pipeline.
|
||||
- [ServiceHosting.md](ServiceHosting.md) — role gating, the Akka cluster, and
|
||||
the optional Wonderware historian sidecar.
|
||||
- [AlarmHistorian.md](AlarmHistorian.md) — the store-and-forward SQLite alarm
|
||||
sink that drains to `IAlarmHistorianWriter`.
|
||||
- [Redundancy.md](Redundancy.md) — driver stability tiers in the redundancy
|
||||
context.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Incremental Sync
|
||||
|
||||
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed, OPC UA Client's upstream namespace change) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
|
||||
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
|
||||
|
||||
## Driver-backend rediscovery — IRediscoverable
|
||||
|
||||
@@ -18,9 +18,8 @@ The driver fires the event with a reason string (for the diagnostic log) and an
|
||||
|
||||
Drivers that implement the capability today:
|
||||
|
||||
- **Galaxy** — polls `galaxy.time_of_last_deploy` in the Galaxy repository DB and fires on change. This is Galaxy-internal change detection, not the platform-wide mechanism.
|
||||
- **TwinCAT** — observes ADS symbol-version-changed notifications (`0x0702`).
|
||||
- **OPC UA Client** — subscribes to the upstream server's `Server/NamespaceArray` change notifications.
|
||||
- **Galaxy** — `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) subscribes to the mxaccessgw gRPC stream (`IGalaxyDeployWatchSource.WatchAsync`) and fires on a new `time_of_last_deploy` value. The gateway polls the Galaxy repository DB internally; the driver side is event-driven.
|
||||
- **TwinCAT** — observes ADS symbol-version-changed notifications (ADS error `DeviceSymbolVersionInvalid`, decimal 1809 / `0x0711`). Note: legacy Beckhoff documentation sometimes cites `0x0702` (`DeviceInvalidGroup`) — that is a transcription error; the correct code is `0x0711` per `TwinCATStatusMapper.AdsSymbolVersionChanged` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs:35`).
|
||||
|
||||
Static drivers (Modbus, S7, AB CIP, AB Legacy, FOCAS) do not implement `IRediscoverable` — their tags only change when a new generation is published from the Config DB. Core sees absence of the interface and skips change-detection wiring for those drivers (decision #54).
|
||||
|
||||
@@ -28,7 +27,7 @@ Static drivers (Modbus, S7, AB CIP, AB Legacy, FOCAS) do not implement `IRedisco
|
||||
|
||||
Tag-set changes authored in the Admin UI (UNS edits, CSV imports, driver-config edits) accumulate in a draft generation and commit via `sp_PublishGeneration`. The delta between the currently-published generation and the proposed next one is computed by `sp_ComputeGenerationDiff`, which drives:
|
||||
|
||||
- The **DiffViewer** in Admin (`src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor`) so operators can preview what will change before clicking Publish.
|
||||
- The publish-preview surface in the Admin UI (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor`, backed by `AdminOperationsClient`) so operators can preview what will change before clicking Publish.
|
||||
- The 409-on-stale-draft flow (decision #161) — a UNS drag-reorder preview carries a `DraftRevisionToken` so Confirm returns `409 Conflict / refresh-required` if the draft advanced between preview and commit.
|
||||
|
||||
After publish, the server's generation applier invokes `IDriver.ReinitializeAsync(driverConfigJson, ct)` on every driver whose `DriverInstance.DriverConfig` row changed in the new generation. Reinitialize is the in-process recovery path for Tier A/B drivers; if it fails the driver is marked `DriverState.Faulted` and its nodes go Bad quality — but the server process stays running. See `docs/v2/driver-stability.md`.
|
||||
@@ -49,7 +48,7 @@ Exceptions during teardown are swallowed per decision #12 — a driver throw mus
|
||||
|
||||
## Scope hint
|
||||
|
||||
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree, and for OPC UA Client where an upstream change may be localized. Null scope falls back to a full-tree diff.
|
||||
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree. Null scope falls back to a full-tree diff.
|
||||
|
||||
## Virtual tags in the rebuild
|
||||
|
||||
@@ -64,6 +63,7 @@ Subscriptions for unchanged references stay live across rebuilds — their ref-c
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs` — backend-change capability
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — discovery orchestration
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs` — `ReinitializeAsync` contract
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs` — publish-flow driver
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs` — publish-flow driver
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs` — cluster singleton invoked by the Admin UI's `AdminOperationsClient`
|
||||
- `docs/v2/config-db-schema.md` — `sp_PublishGeneration` + `sp_ComputeGenerationDiff`
|
||||
- `docs/v2/admin-ui.md` — DiffViewer + draft-revision-token flow
|
||||
|
||||
+57
-46
@@ -1,88 +1,99 @@
|
||||
# OPC UA Server
|
||||
|
||||
The OPC UA server component (`src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs`) hosts the OPC UA stack and exposes one browsable subtree per registered driver. The server itself is driver-agnostic — Galaxy/MXAccess, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client are all plugged in as `IDriver` implementations via the capability interfaces in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
|
||||
The OPC UA server component (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) hosts the OPC UA stack and exposes a browsable address space built from the registered drivers. The server itself is driver-agnostic — Galaxy/MXAccess, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client are all plugged in as `IDriver` implementations via the capability interfaces in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
|
||||
|
||||
In v2 the Server and Admin processes were fused into a single role-gated `ZB.MOM.WW.OtOpcUa.Host` binary. Which subsystems start (OPC UA endpoint, Admin UI, control plane, driver runtime) is decided by the `OTOPCUA_ROLES` gate, not by running separate executables. See `docs/ServiceHosting.md` for the role model.
|
||||
|
||||
## Composition
|
||||
|
||||
`OtOpcUaServer` subclasses the OPC Foundation `StandardServer` and wires:
|
||||
`OtOpcUaSdkServer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs`) subclasses the OPC Foundation `StandardServer` and wires a single custom node manager:
|
||||
|
||||
- A `DriverHost` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs`) which registers drivers and holds the per-instance `IDriver` references.
|
||||
- One `DriverNodeManager` per registered driver (`src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`), constructed in `CreateMasterNodeManager`. Each manager owns its own namespace URI (`urn:OtOpcUa:{DriverInstanceId}`) and exposes the driver as a subtree under the standard `Objects` folder.
|
||||
- A `CapabilityInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`) per driver instance, keyed on `(DriverInstanceId, HostName, DriverCapability)` against the shared `DriverResiliencePipelineBuilder`. Every Read/Write/Discovery/Subscribe/HistoryRead/AlarmSubscribe call on the driver flows through this invoker so the Polly pipeline (retry / timeout / breaker / bulkhead) applies. The OTOPCUA0001 Roslyn analyzer enforces the wrapping at compile time.
|
||||
- An `IUserAuthenticator` (LDAP in production, injected stub in tests) for `UserName` token validation in the `ImpersonateUser` hook.
|
||||
- Optional `AuthorizationGate` + `NodeScopeResolver` (Phase 6.2) that sit in front of every dispatch call. In lax mode the gate passes through when the identity lacks LDAP groups so existing integration tests keep working; strict mode (`Authorization:StrictMode = true`) denies those cases.
|
||||
- `CreateMasterNodeManager` constructs one `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) — a `CustomNodeManager2` subclass that owns the writable address space under the namespace `https://zb.com/otopcua/ns` and a single `OtOpcUa` root folder organized under the standard `Objects` folder. It is wrapped in a `MasterNodeManager` with no additional core managers.
|
||||
- `OtOpcUaSdkServer.NodeManager` exposes the live node manager after `StartAsync`, so the hosting layer can wrap it in a `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) and hand it to `OpcUaPublishActor`.
|
||||
|
||||
`OtOpcUaServer.DriverNodeManagers` exposes the materialized list so the hosting layer can walk each one post-start and call `GenericDriverNodeManager.BuildAddressSpaceAsync(manager)` — the manager is passed as its own `IAddressSpaceBuilder`.
|
||||
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (`DriverInstanceActor` → `OpcUaPublishActor`), and `OpcUaPublishActor` writes them into the node manager through the `IOpcUaAddressSpaceSink` seam. `OtOpcUaNodeManager.EnsureFolder` / `EnsureVariable` materialize the UNS folder + variable hierarchy; `WriteValue` / `WriteAlarmState` push runtime values and fire `ClearChangeMasks` so subscribed clients see updates.
|
||||
|
||||
The driver-agnostic walk that turns a driver's discovery into folder/variable calls lives in `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`): it walks `ITagDiscovery.DiscoverAsync` into an `IAddressSpaceBuilder`, captures alarm-condition sinks for variables flagged via `IVariableHandle.MarkAsAlarmCondition`, subscribes to `IAlarmSource.OnAlarmEvent`, and routes each alarm transition to the sink registered for its `SourceNodeId`.
|
||||
|
||||
The lifecycle facade `OpcUaApplicationHost` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`) owns the `ApplicationInstance` + `ApplicationConfiguration` lifetime, starts the `StandardServer`, and attaches the `ImpersonateUser` hook (see Session impersonation).
|
||||
|
||||
## Resilience and capability dispatch
|
||||
|
||||
Driver-capability calls (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync/UnsubscribeAsync`, the `IHostConnectivityProbe` probe loop, `IAlarmSource` surfaces, and the four `IHistoryProvider` reads) are routed through a `CapabilityInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`) so the Polly resilience pipeline (retry / timeout / breaker / bulkhead) applies. There is one invoker per `(DriverInstance, IDriver)` pair; all invokers share the process-singleton `DriverResiliencePipelineBuilder`, which keys pipelines on `(DriverInstanceId, hostName, DriverCapability)`. Per-instance resilience options come from `DriverTypeRegistry` (the driver's tier) plus per-instance JSON overrides parsed from `DriverInstance.ResilienceConfig` by `DriverResilienceOptionsParser`.
|
||||
|
||||
The `OTOPCUA0001` Roslyn analyzer (`src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`, category `OtOpcUa.Resilience`, severity Warning) flags direct driver-capability calls that bypass the invoker.
|
||||
|
||||
| Capability | Surface | Invoker entry point |
|
||||
|---|---|---|
|
||||
| Read | `IReadable.ReadAsync` | `ExecuteAsync(DriverCapability.Read, host, …)` |
|
||||
| Write | `IWritable.WriteAsync` | `ExecuteWriteAsync(host, isIdempotent, …)` — disables retries for non-idempotent writes per `WriteIdempotentAttribute` / decisions #44-45, #143 |
|
||||
| Discovery | `ITagDiscovery.DiscoverAsync` | `ExecuteAsync(DriverCapability.Discover, host, …)` |
|
||||
| Subscribe / Unsubscribe | `ISubscribable.SubscribeAsync/UnsubscribeAsync` | `ExecuteAsync(DriverCapability.Subscribe, host, …)` |
|
||||
| HistoryRead (raw / processed / at-time / events) | `IHistoryProvider.*Async` | `ExecuteAsync(DriverCapability.HistoryRead, host, …)` |
|
||||
| Alarm subscribe / unsubscribe / acknowledge | `IAlarmSource.SubscribeAlarmsAsync/UnsubscribeAlarmsAsync/AcknowledgeAsync` | via `AlarmSurfaceInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs`), which fans out per host |
|
||||
|
||||
The host name fed to the invoker comes from `IPerCallHostResolver.ResolveHost(fullReference)` when the driver implements it (multi-host drivers: AB CIP, Modbus, FOCAS, TwinCAT, AB Legacy resolve per device). Single-host drivers fall back to `DriverInstanceId`, preserving the per-instance pipeline-key semantics (decision #144).
|
||||
|
||||
## Configuration
|
||||
|
||||
Server wiring used to live in `appsettings.json`. It now flows from the SQL Server **Config DB**: `ServerInstance` + `DriverInstance` + `Tag` + `NodeAcl` rows are published as a *generation* via `sp_PublishGeneration` and loaded into the running process by the generation applier. The Admin UI (Blazor Server, `docs/v2/admin-ui.md`) is the operator surface — drafts accumulate edits; `sp_ComputeGenerationDiff` drives the DiffViewer preview; a UNS drag-reorder carries a `DraftRevisionToken` so Confirm re-checks against the current draft and returns 409 if it advanced (decision #161). See `docs/v2/config-db-schema.md` for the schema.
|
||||
Tenant-scoped server wiring flows from the SQL Server **Config DB**, not from `appsettings.json`: `ServerInstance` + `DriverInstance` + `Tag` + `NodeAcl` rows are published as a *generation* by `sp_PublishGeneration` and loaded into the running process by the generation applier. The Admin UI (Blazor Server, `docs/v2/admin-ui.md`) is the operator surface — drafts accumulate edits and `sp_ComputeGenerationDiff` drives the DiffViewer preview before publish. Optimistic concurrency uses each entity's `RowVersion`; a stale edit fails the publish/save rather than silently overwriting. See `docs/v2/config-db-schema.md` for the schema.
|
||||
|
||||
Environmental knobs that aren't per-tenant (bind address, port, PKI path) still live in `appsettings.json` on the Server project; everything tenant-scoped moved to the Config DB.
|
||||
Environmental knobs that aren't per-tenant — bind address, port, PKI store root, security profiles — are supplied to `OpcUaApplicationHostOptions` and resolved from `appsettings.json` on the Host project.
|
||||
|
||||
## Transport
|
||||
|
||||
The server binds one TCP endpoint per `ServerInstance` (default `opc.tcp://0.0.0.0:4840`). The `ApplicationConfiguration` is built programmatically in the `OpcUaApplicationHost` — there are no UA XML files. Security profiles (`None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`) are resolved from the `ServerInstance.Security` JSON at startup; the default profile is still `None` for backward compatibility. User token policies (`Anonymous`, `UserName`) are attached based on whether LDAP is configured. See `docs/security.md` for hardening.
|
||||
The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpcUa` (defaults `0.0.0.0:4840`). The `ApplicationConfiguration` is built programmatically in `OpcUaApplicationHost.BuildConfigurationAsync` — there are no UA XML files unless `ApplicationConfigPath` is set. Security profiles are listed in `OpcUaApplicationHostOptions.EnabledSecurityProfiles`; by default all three baseline profiles are exposed (`None`, `Basic256Sha256` + Sign, `Basic256Sha256` + SignAndEncrypt) and the SDK publishes one endpoint descriptor per profile. Production deployments typically drop `None`. User token policies (`Anonymous`, `UserName`) are always attached; the `UserName` policy is SDK-encrypted with the server certificate so it works on `None` endpoints too. See `docs/security.md` for hardening.
|
||||
|
||||
## Session impersonation
|
||||
|
||||
`OtOpcUaServer.OnImpersonateUser` handles the three token types:
|
||||
`OpcUaApplicationHost` subscribes to `SessionManager.ImpersonateUser` after `ApplicationInstance.Start`. The handler (`HandleImpersonation`) deals with the token types as follows:
|
||||
|
||||
- `AnonymousIdentityToken` → default anonymous `UserIdentity`.
|
||||
- `UserNameIdentityToken` → `IUserAuthenticator.AuthenticateAsync` validates the credential (`LdapUserAuthenticator` in production). On success, the resolved display name + LDAP-derived roles are wrapped in a `RoleBasedIdentity` that implements `IRoleBearer`. `DriverNodeManager.OnWriteValue` reads these roles via `context.UserIdentity is IRoleBearer` and applies `WriteAuthzPolicy` per write.
|
||||
- Anything else → `BadIdentityTokenInvalid`.
|
||||
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
|
||||
- `AnonymousIdentityToken` and X.509 tokens → the handler returns without intervening, so the SDK's default validation stands.
|
||||
|
||||
The Phase 6.2 `AuthorizationGate` runs on top of this baseline: when configured it consults the cluster's permission trie (loaded from `NodeAcl` rows) using the session's `UserAuthorizationState` and can deny Read / HistoryRead / Write / Browse independently per tag. See `docs/v2/acl-design.md`.
|
||||
Decryption failures and authenticator exceptions also map to `BadIdentityTokenRejected`.
|
||||
|
||||
## Dispatch
|
||||
## Authorization
|
||||
|
||||
Every service call the stack hands to `DriverNodeManager` is translated to the driver's capability interface and routed through `CapabilityInvoker`:
|
||||
|
||||
| Service | Capability | Invoker method |
|
||||
|---|---|---|
|
||||
| Read | `IReadable.ReadAsync` | `ExecuteAsync(DriverCapability.Read, host, …)` |
|
||||
| Write | `IWritable.WriteAsync` | `ExecuteWriteAsync(host, isIdempotent, …)` — honors `WriteIdempotentAttribute` (#143) |
|
||||
| CreateMonitoredItems / DeleteMonitoredItems | `ISubscribable.SubscribeAsync/UnsubscribeAsync` | `ExecuteAsync(DriverCapability.Subscribe, host, …)` |
|
||||
| HistoryRead (raw / processed / at-time / events) | `IHistoryProvider.*Async` | `ExecuteAsync(DriverCapability.HistoryRead, host, …)` |
|
||||
| ConditionRefresh / Acknowledge | `IAlarmSource.*Async` | via `AlarmSurfaceInvoker` (fans out per host) |
|
||||
|
||||
The host name fed to the invoker comes from `IPerCallHostResolver.ResolveHost(fullReference)` when the driver implements it (multi-host drivers: AB CIP, Modbus with per-device options). Single-host drivers fall back to `DriverInstanceId`, preserving pre-Phase-6.1 pipeline-key semantics (decision #144).
|
||||
Node-level authorization is backed by a permission trie under `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` (`PermissionTrie`, `PermissionTrieBuilder`, `PermissionTrieCache`, `TriePermissionEvaluator`, `NodeScope`, `UserAuthorizationState`, `AuthorizationDecision`). The trie is built from `NodeAcl` rows and a session's `UserAuthorizationState`, and an `IPermissionEvaluator` can return a per-tag `AuthorizationDecision` for Read / HistoryRead / Write / Browse independently. See `docs/v2/acl-design.md`.
|
||||
|
||||
## Redundancy
|
||||
|
||||
`Redundancy.Enabled = true` on the `ServerInstance` activates the `RedundancyCoordinator` + `ServiceLevelCalculator` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/`). Standard OPC UA redundancy nodes (`Server/ServerRedundancy/RedundancySupport`, `ServerUriArray`, `Server/ServiceLevel`) are populated on startup; `ServiceLevel` recomputes whenever any driver's `DriverHealth` changes. The apply-lease mechanism prevents two instances from concurrently applying a generation. See `docs/Redundancy.md`.
|
||||
`Redundancy.Enabled = true` on the `ServerInstance` activates the `RedundancyStateActor` + `ServiceLevelCalculator` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/`). The OPC UA `Server/ServiceLevel` node (`VariableIds.Server_ServiceLevel`) is recomputed and republished via `SdkServiceLevelPublisher` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs`, wired as `IServiceLevelPublisher`) whenever role or driver-health changes; `ServiceLevelCalculator` produces a 0–255 value where higher means more authoritative, so the primary advertises a higher ServiceLevel than the secondary. Clients also read the standard `Server/ServerRedundancy/RedundancySupport` and `Server/ServerRedundancy/ServerUriArray` properties the SDK exposes on the ServerObject. An apply-lease prevents two instances from concurrently applying a generation. See `docs/Redundancy.md`.
|
||||
|
||||
Peer endpoints are advertised through the standard `Server.ServerArray` property: `OpcUaApplicationHost` appends `OpcUaApplicationHostOptions.PeerApplicationUris` to `IServerInternal.ServerUris` after start so warm-redundancy clients can discover the partner.
|
||||
|
||||
## Server class hierarchy
|
||||
|
||||
### OtOpcUaServer extends StandardServer
|
||||
### OtOpcUaSdkServer extends StandardServer
|
||||
|
||||
- **`CreateMasterNodeManager`** — Iterates `_driverHost.RegisteredDriverIds`, builds one `DriverNodeManager` per driver with its own `CapabilityInvoker` + resilience options (tier from `DriverTypeRegistry`, per-instance JSON overrides from `DriverInstance.ResilienceConfig` via `DriverResilienceOptionsParser`). The managers are wrapped in a `MasterNodeManager` with no additional core managers.
|
||||
- **`OnServerStarted`** — Hooks `SessionManager.ImpersonateUser` for LDAP auth. Redundancy + server-capability population happens via `OpcUaApplicationHost`.
|
||||
- **`LoadServerProperties`** — Manufacturer `OtOpcUa`, Product `OtOpcUa.Server`, ProductUri `urn:OtOpcUa:Server`.
|
||||
- **`CreateMasterNodeManager`** — Constructs the single `OtOpcUaNodeManager` and wraps it in a `MasterNodeManager` with no extra core managers.
|
||||
- **`NodeManager`** — Public accessor exposing the live `OtOpcUaNodeManager` once the SDK has bootstrapped (null until `CreateMasterNodeManager` runs).
|
||||
|
||||
### ServerCapabilities
|
||||
|
||||
`OpcUaApplicationHost` populates `Server/ServerCapabilities` with `StandardUA2017`, `en` locale, 100 ms `MinSupportedSampleRate`, 4 MB message caps, and per-operation limits (1000 per Read/Write/Browse/TranslateBrowsePaths/MonitoredItems/HistoryRead; 0 for MethodCall/NodeManagement/HistoryUpdate).
|
||||
`ApplicationName`, `ApplicationUri` (`urn:OtOpcUa`), and `ProductUri` (`https://zb.com/otopcua`) come from `OpcUaApplicationHostOptions`, which the `ApplicationConfiguration` is built from in `OpcUaApplicationHost`.
|
||||
|
||||
## Certificate handling
|
||||
|
||||
Certificate stores default to `%LOCALAPPDATA%\OPC Foundation\pki\` (directory-based):
|
||||
Certificate stores are directory-based under `OpcUaApplicationHostOptions.PkiStoreRoot` (default `pki`, relative to the host's working directory):
|
||||
|
||||
| Store | Path suffix |
|
||||
|---|---|
|
||||
| Own | `pki/own` |
|
||||
| Own (application certificate) | `pki/own` |
|
||||
| Trusted issuers | `pki/issuer` |
|
||||
| Trusted peers | `pki/trusted` |
|
||||
| Rejected | `pki/rejected` |
|
||||
|
||||
`Security.AutoAcceptClientCertificates` (default `true`) and `RejectSHA1Certificates` (default `true`) are honored. The server certificate is always created — even for `None`-only deployments — because `UserName` token encryption needs it.
|
||||
`OpcUaApplicationHostOptions.AutoAcceptUntrustedClientCertificates` (default `false`) controls whether unknown client certificates are auto-trusted on first connection; production deployments leave it off and operators promote peers via the Admin UI. The application instance certificate is auto-created (SDK defaults: 2048-bit, 12-month lifetime) on first start against a fresh PKI tree, and the server certificate is always created — even for `None`-only deployments — because `UserName` token encryption needs it.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs` — `StandardServer` subclass + `ImpersonateUser` hook
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — per-driver `CustomNodeManager2` + dispatch surface
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs` — programmatic `ApplicationConfiguration` + lifecycle
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs` — driver registration
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — Polly pipeline entry point
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — Phase 6.2 permission trie + evaluator
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs` — stack-to-evaluator bridge
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs` — `StandardServer` subclass wiring the single node manager
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs` — programmatic `ApplicationConfiguration` + lifecycle + `ImpersonateUser` hook + ServerArray population
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — `CustomNodeManager2` owning the writable address space
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` — `IOpcUaAddressSpaceSink` adapter the actor system pushes into
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs` — publishes the redundancy `ServiceLevel` node
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — driver-agnostic discovery walk + alarm routing
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs` — process-local driver registration + lifecycle
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — Polly pipeline entry point for capability calls
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs` — per-host fan-out wrapper for `IAlarmSource`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`)
|
||||
|
||||
+7
-4
@@ -26,19 +26,21 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess
|
||||
| [OpcUaServer.md](OpcUaServer.md) | Top-level server architecture — Core, driver dispatch, Config DB, generations |
|
||||
| [AddressSpace.md](AddressSpace.md) | `GenericDriverNodeManager` + `ITagDiscovery` + `IAddressSpaceBuilder` |
|
||||
| [ReadWriteOperations.md](ReadWriteOperations.md) | OPC UA Read/Write → `CapabilityInvoker` → `IReadable`/`IWritable` |
|
||||
| [DriverLifecycle.md](DriverLifecycle.md) | Server-side driver lifecycle + infrastructure contracts (`IDriverFactory`, `IDriverProbe`, `IDriverSupervisor`, `IDriverHealthPublisher`, `IDriverConfigEditor`, `IHistorianDataSource`) + the Commons library |
|
||||
| [Subscriptions.md](v1/Subscriptions.md) | Monitored items → `ISubscribable` + per-driver subscription refcount (v1 archive) |
|
||||
| [AlarmTracking.md](v1/AlarmTracking.md) | `IAlarmSource` + `AlarmSurfaceInvoker` + OPC UA alarm conditions (v1 archive) |
|
||||
| [AlarmTracking.md](AlarmTracking.md) | `IAlarmSource` + `AlarmSurfaceInvoker` + OPC UA alarm conditions — native Galaxy alarms end-to-end (live) |
|
||||
| [AlarmTracking.md](v1/AlarmTracking.md) | Original alarm-tracking write-up (v1 archive) |
|
||||
| [AlarmHistorian.md](AlarmHistorian.md) | `Core.AlarmHistorian` store-and-forward SQLite sink — `SqliteStoreAndForwardSink`, `IAlarmHistorianWriter`, dead-letter/retry/eviction |
|
||||
| [DataTypeMapping.md](v1/DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types (v1 archive — live mapping is in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs`) |
|
||||
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
|
||||
| [HistoricalDataAccess.md](v1/HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability (v1 archive) |
|
||||
| [VirtualTags.md](VirtualTags.md) | `Core.Scripting` + `Core.VirtualTags` — Roslyn script sandbox, engine, dispatch alongside driver tags |
|
||||
| [ScriptedAlarms.md](ScriptedAlarms.md) | `Core.ScriptedAlarms` — script-predicate `IAlarmSource` + Part 9 state machine |
|
||||
|
||||
Two Core subsystems are shipped without a dedicated top-level doc; see the section in the linked doc:
|
||||
One Core subsystem is shipped without a dedicated top-level doc; see the section in the linked doc:
|
||||
|
||||
| Project | See |
|
||||
|---------|-----|
|
||||
| `Core.AlarmHistorian` | [AlarmTracking.md](v1/AlarmTracking.md) § Alarm historian sink (v1 archive) |
|
||||
| `Analyzers` (Roslyn OTOPCUA0001) | [security.md](security.md) § OTOPCUA0001 Analyzer |
|
||||
|
||||
### Drivers
|
||||
@@ -55,11 +57,12 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [Configuration.md](Configuration.md) | Live appsettings + environment-variable reference (current state) |
|
||||
| [Configuration.md](v1/Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — `OTOPCUA_GALAXY_*` env vars now live in mxaccessgw config) |
|
||||
| [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics |
|
||||
| [Reservations.md](Reservations.md) | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow |
|
||||
| [ServiceHosting.md](ServiceHosting.md) | Two-process deploy (Server + Admin) install/uninstall, plus the optional `OtOpcUaWonderwareHistorian` sidecar |
|
||||
| [ServiceHosting.md](ServiceHosting.md) | Single fused `OtOpcUa.Host` binary install/uninstall with `OTOPCUA_ROLES` gating, plus the optional `OtOpcUaWonderwareHistorian` sidecar |
|
||||
| [StatusDashboard.md](StatusDashboard.md) | Pointer — superseded by [v2/admin-ui.md](v2/admin-ui.md) |
|
||||
|
||||
### Client tooling
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Read/Write Operations
|
||||
|
||||
`DriverNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) wires the OPC UA stack's per-variable `OnReadValue` and `OnWriteValue` hooks to each driver's `IReadable` and `IWritable` capabilities. Every dispatch flows through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers.
|
||||
The v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value.
|
||||
|
||||
## Driver vs virtual dispatch
|
||||
|
||||
@@ -52,7 +52,7 @@ Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA sta
|
||||
|
||||
## HistoryRead
|
||||
|
||||
`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/HistoricalDataAccess.md`.
|
||||
`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/v1/HistoricalDataAccess.md`.
|
||||
|
||||
## Failure isolation
|
||||
|
||||
@@ -60,8 +60,8 @@ Per decision #12, exceptions in the driver's capability call are logged and conv
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `OnReadValue` / `OnWriteValue` hooks
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs` — classification-to-role policy
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs` — Phase 6.2 trie gate
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — address-space population and alarm routing during discovery
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`) that gates Read/Write/Subscribe per the session's resolved LDAP groups
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs`
|
||||
|
||||
+75
-16
@@ -2,23 +2,48 @@
|
||||
|
||||
## Overview
|
||||
|
||||
OtOpcUa supports OPC UA **non-transparent** warm/hot redundancy. Two or more `OtOpcUa.Host` processes run side-by-side, share the same Config DB, and join the same Akka.NET cluster. Each process owns a distinct `ApplicationUri`; OPC UA clients see both endpoints via the standard `ServerUriArray` and pick one based on the `ServiceLevel` byte that each server publishes.
|
||||
OtOpcUa supports OPC UA **non-transparent** warm/hot redundancy. Two or more `OtOpcUa.Host` processes run side-by-side, share the same Config DB, and join the same Akka.NET cluster. Each process owns a distinct `ApplicationUri`; OPC UA clients discover both endpoints by reading `Server.ServerArray` (NodeId `i=2254`) on either node and pick one based on the `ServiceLevel` byte that each server publishes.
|
||||
|
||||
> **v2 change.** v1's operator-managed `ClusterNode.RedundancyRole` column + `RedundancyCoordinator` / `ApplyLeaseRegistry` / `PeerHttpProbeLoop` are gone. Primary/secondary is now derived from **Akka cluster role-leader** for the `driver` role. The operator no longer writes a role into the DB; cluster topology + health drive ServiceLevel automatically.
|
||||
> **Discovery surface.** The `ServerArray` path on the `Server` object is what each node populates with self + peer `ApplicationUri`s — see `OpcUaApplicationHost.PopulateServerArray` and the per-node `PeerApplicationUris` option below. The redundancy-object-type `ServerUriArray` proper (a child of `Server.ServerRedundancy`) remains deferred pending an SDK object-type upgrade; clients should read `Server.ServerArray` for peer discovery today.
|
||||
|
||||
> **v2 change.** v1's operator-managed `ClusterNode.RedundancyRole` column + `RedundancyCoordinator` / `ApplyLeaseRegistry` / `PeerHttpProbeLoop` are gone. Primary/secondary is now derived from **Akka cluster role-leader** for the `driver` role. The operator no longer writes a role into the DB; cluster topology (specifically the `driver` role-leader) drives ServiceLevel automatically.
|
||||
|
||||
The runtime pieces live in:
|
||||
|
||||
| Component | Project | Role |
|
||||
|---|---|---|
|
||||
| `ServiceLevelCalculator` | `OtOpcUa.ControlPlane.Redundancy` | Pure function `(NodeHealthInputs) → byte`. No side effects. |
|
||||
| `RedundancyStateActor` | `OtOpcUa.ControlPlane.Redundancy` | Admin-role cluster singleton; subscribes to cluster topology events, debounces 250ms, broadcasts `RedundancyStateChanged` on the `redundancy-state` DPS topic. |
|
||||
| `DbHealthProbeActor` | `OtOpcUa.Runtime.Health` | Per-node; runs `SELECT 1` against ConfigDb every 5s. Read by health endpoint + redundancy calc. |
|
||||
| `PeerOpcUaProbeActor` | `OtOpcUa.Runtime.Health` | Per-node; pings peer `opc.tcp://peer:4840` (real probe call is staged for follow-up F12). |
|
||||
| `OpcUaPublishActor` | `OtOpcUa.Runtime.OpcUa` | Per-driver-node; subscribes to the `redundancy-state` topic, maps the local node's role to a ServiceLevel byte (see below), and forwards it to `IServiceLevelPublisher`. |
|
||||
| `IServiceLevelPublisher` / `SdkServiceLevelPublisher` | `OtOpcUa.Commons.OpcUa` / `OtOpcUa.OpcUaServer` | Writes the byte into the SDK's `Server.ServiceLevel` Variable. Production binds `DeferredServiceLevelPublisher`, which swaps in the real `SdkServiceLevelPublisher` once the SDK is up (it needs `IServerInternal`, available only after `StandardServer.Start`); until then writes route through `NullServiceLevelPublisher`. |
|
||||
| `ServiceLevelCalculator` | `OtOpcUa.ControlPlane.Redundancy` | Pure function `(NodeHealthInputs) → byte` — the fuller DB/probe-aware tiering (see truth table below). Covered by `ServiceLevelCalculatorTests`; **not yet wired into the live driver publish path**, which uses the coarse role mapping in `OpcUaPublishActor`. |
|
||||
| `DbHealthProbeActor` | `OtOpcUa.Runtime.Health` | Per-node; runs `SELECT 1` against ConfigDb every 5s. Read by health endpoint. |
|
||||
| `PeerOpcUaProbeActor` | `OtOpcUa.Runtime.Health` | Per-node; pings peer `opc.tcp://peer:4840` with a TCP connect (2s timeout) and publishes the result on the `redundancy-state` topic. A full secure-channel Hello handshake is a possible future upgrade; the TCP connect is the current real probe. |
|
||||
| `ClusterRoleInfo` | `OtOpcUa.Cluster` | Live view of cluster membership + role-leader; exposes `IClusterRoleInfo` to the rest of the host. |
|
||||
|
||||
## ServiceLevel tiers (Part 5 §6.5)
|
||||
## ServiceLevel tiers
|
||||
|
||||
`ServiceLevelCalculator.Compute(NodeHealthInputs)` returns a byte in 0..255 by tier:
|
||||
### Live driver-side mapping (current)
|
||||
|
||||
`OpcUaPublishActor.HandleRedundancyStateChanged` maps the local node's role
|
||||
(from the `RedundancyStateChanged` snapshot) to a ServiceLevel byte and forwards
|
||||
it through `IServiceLevelPublisher` to the SDK's `Server.ServiceLevel` Variable:
|
||||
|
||||
| Local role | Byte |
|
||||
|---|---|
|
||||
| `Primary` and `driver` role-leader | 240 |
|
||||
| `Primary` (not role-leader) | 200 |
|
||||
| `Secondary` | 100 |
|
||||
| `Detached` (no `driver` role) | 0 |
|
||||
|
||||
Roles come from `RedundancyStateActor.BuildSnapshot`: a node with the `driver`
|
||||
role is `Primary` when it holds the `driver` role-leader lease, otherwise
|
||||
`Secondary`; a node without the `driver` role is `Detached`.
|
||||
|
||||
### Full health-aware tiering (`ServiceLevelCalculator`)
|
||||
|
||||
`ServiceLevelCalculator.Compute(NodeHealthInputs)` is the fuller, DB/probe-aware
|
||||
calculation. It is unit-tested but **not yet on the live publish path** — the
|
||||
driver-side mapping above is what actually drives the SDK today.
|
||||
|
||||
| Tier | Byte | Condition |
|
||||
|---|---|---|
|
||||
@@ -26,16 +51,16 @@ The runtime pieces live in:
|
||||
| Critically degraded | 100 | ConfigDb unreachable AND data is stale. |
|
||||
| Stale | 200 | Data stale but ConfigDb reachable. |
|
||||
| Healthy follower | 240 | DB ok + OPC UA probe ok + not stale. |
|
||||
| Healthy leader | 250 | Healthy + this node is the `driver` role-leader. |
|
||||
| Healthy leader | 250 | Healthy follower (240) + a `+10` bonus when this node is the `driver` role-leader. |
|
||||
|
||||
Drivers write their computed byte into the OPC UA `ServiceLevel` Variable on each refresh. Clients with the standard redundancy heuristic ("pick the highest ServiceLevel") therefore prefer the role-leader and fall back to followers on its degradation.
|
||||
Either way, clients with the standard redundancy heuristic ("pick the highest
|
||||
ServiceLevel") prefer the `driver` role-leader and fall back to followers on its
|
||||
degradation.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Cluster topology event ──┐
|
||||
DB health probe ─────────┤
|
||||
OPC UA peer probe ───────┤
|
||||
▼
|
||||
RedundancyStateActor (admin singleton)
|
||||
│ debounce 250ms
|
||||
@@ -44,14 +69,22 @@ OPC UA peer probe ───────┤
|
||||
│
|
||||
▼
|
||||
Driver nodes' OpcUaPublishActor
|
||||
│ role → byte (240/200/100/0)
|
||||
▼
|
||||
IServiceLevelPublisher (SdkServiceLevelPublisher)
|
||||
│
|
||||
▼
|
||||
ServiceLevelCalculator → byte
|
||||
│
|
||||
▼
|
||||
OPC UA ServiceLevel Variable
|
||||
OPC UA Server.ServiceLevel Variable
|
||||
```
|
||||
|
||||
Today only cluster topology drives the published ServiceLevel.
|
||||
`PeerOpcUaProbeActor` and `DbHealthProbeActor` also run per-node — the peer probe
|
||||
publishes `OpcUaProbeResult` onto the `redundancy-state` topic and the DB probe
|
||||
backs the health endpoint — but their outputs are not yet consumed by
|
||||
`RedundancyStateActor` or folded into the published byte. They are the inputs the
|
||||
fuller `ServiceLevelCalculator` truth table is designed to use once that path goes
|
||||
live.
|
||||
|
||||
The admin singleton is the cluster's only `RedundancyStateActor`. If the admin leader fails over, the new admin node spins up its replacement, re-subscribes to cluster events, and publishes a fresh snapshot from the current `Cluster.State`. There is no DB-persisted state to recover.
|
||||
|
||||
## Configuration
|
||||
@@ -76,7 +109,23 @@ OTOPCUA_ROLES=admin,driver
|
||||
|
||||
Both nodes share the same `ConfigDb` connection string; `Cluster.PublicHostname` + `Roles` are what makes them distinct in cluster gossip. The first node bootstraps the cluster (its address goes in `SeedNodes`); the second node joins via the same `SeedNodes` list.
|
||||
|
||||
There is no longer a `Node:NodeId` setting, no `ClusterNode.RedundancyRole`, no `ServiceLevelBase`. NodeId is derived as `host:port` of the cluster `PublicHostname` (see `ClusterRoleInfo.LocalNode` for the formula).
|
||||
There is no longer a `Node:NodeId` setting and no `ClusterNode.RedundancyRole` column (the V2 migration dropped it — primary/secondary is now derived from cluster role-leadership). NodeId is derived as `host:port` of the cluster `PublicHostname` (see `ClusterRoleInfo.LocalNode` for the formula).
|
||||
|
||||
The `ClusterNode.ServiceLevelBase` column still exists and is editable in the Admin UI (NodeEdit / Cluster Redundancy pages), but it no longer drives the runtime ServiceLevel — that value is computed from cluster role/health and published per the mapping above, independent of this stored preference.
|
||||
|
||||
### Peer URI advertising
|
||||
|
||||
Each node advertises its partner via `OpcUaApplicationHostOptions.PeerApplicationUris` (an `IList<string>`, default empty). `OpcUaApplicationHost.PopulateServerArray` appends each configured peer URI to the SDK's `IServerInternal.ServerUris` string table after server startup, so that `Server.ServerArray` reads served by `OnReadServerArray` return both self + peers. The options bind from the `OpcUa` config section (see `Program.cs` — `AddValidatedOptions<OpcUaApplicationHostOptions>(…, "OpcUa")`). Set this per-node in `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"PeerApplicationUris": ["urn:node-b:OtOpcUa"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Node A lists Node B's `ApplicationUri` and vice-versa. Validated by `DualEndpointTests` in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/` — boots two `OpcUaApplicationHost` instances on loopback, asserts a real OPCFoundation client `Session` reading `Server.ServerArray` from Node A sees both URIs.
|
||||
|
||||
## Split-brain
|
||||
|
||||
@@ -88,6 +137,16 @@ There is no operator-driven role swap during a partition. Failover is what the c
|
||||
|
||||
The OtOpcUa Client CLI at `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md).
|
||||
|
||||
## Observability
|
||||
|
||||
`OpcUaPublishActor` emits one metric on every ServiceLevel transition (it suppresses no-op repeats of the same byte):
|
||||
|
||||
| Metric | Type | Notes |
|
||||
|---|---|---|
|
||||
| `otopcua.redundancy.service_level_change` | Counter (`{change}`) | OPC UA `Server.ServiceLevel` transitions emitted by the redundancy state. Tagged with `level` = the new byte. |
|
||||
|
||||
The meter is defined on `OtOpcUaTelemetry` (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs`); it surfaces through whatever OpenTelemetry exporter the host configures.
|
||||
|
||||
## Depth reference
|
||||
|
||||
For the full design — message contracts, tiered calculator truth table, recovery semantics — see `docs/plans/2026-05-26-akka-hosting-alignment-design.md` §6.
|
||||
|
||||
+17
-16
@@ -52,7 +52,7 @@ is refreshed, and they are eventually *released* — but never silently deleted.
|
||||
| `ClusterId` | The first cluster to publish the reservation. |
|
||||
| `FirstPublishedAt` / `FirstPublishedBy` | When and by whom the claim was first made. |
|
||||
| `LastPublishedAt` | Refreshed on every subsequent publish that re-asserts the same `(Kind, Value, EquipmentUuid)`. |
|
||||
| `ReleasedAt` / `ReleasedBy` / `ReleaseReason` | Non-null once a FleetAdmin explicitly releases the claim. A row with `ReleasedAt IS NULL` is *active*. |
|
||||
| `ReleasedAt` / `ReleasedBy` / `ReleaseReason` | Non-null once an Administrator explicitly releases the claim. `ReleasedBy` is the LDAP operator name (passed explicitly as `@ReleasedBy`; not `SUSER_SNAME()`). A row with `ReleasedAt IS NULL` is *active*. |
|
||||
|
||||
There is no foreign key from `EquipmentUuid` / `ClusterId` to their tables — by
|
||||
design, so a reservation survives the deletion or disabling of the equipment
|
||||
@@ -99,14 +99,16 @@ being disabled, the generation being superseded, or a rollback.
|
||||
|
||||
### 4. Release
|
||||
|
||||
Reusing an identifier for a **different** piece of equipment requires a
|
||||
FleetAdmin to explicitly release the existing claim. Release runs
|
||||
Reusing an identifier for a **different** piece of equipment requires an
|
||||
Administrator to explicitly release the existing claim. Release runs
|
||||
`sp_ReleaseExternalIdReservation`, which:
|
||||
|
||||
- Requires a non-empty **reason** — a hard audit invariant; the procedure
|
||||
raises an error without one.
|
||||
- Stamps `ReleasedAt`, `ReleasedBy` (`SUSER_SNAME()`), and `ReleaseReason`
|
||||
rather than deleting the row, so the history is preserved.
|
||||
- Requires a non-empty **`@ReleasedBy`** — the LDAP operator name supplied
|
||||
by the caller; the procedure raises an error without it.
|
||||
- Stamps `ReleasedAt`, `ReleasedBy` (the supplied operator name), and
|
||||
`ReleaseReason` rather than deleting the row, so the history is preserved.
|
||||
- Once released, the `(Kind, Value)` pair is free — a different
|
||||
`EquipmentUuid` can claim it on a future publish.
|
||||
|
||||
@@ -116,20 +118,19 @@ permanent for the life of the asset.
|
||||
|
||||
## The Admin page
|
||||
|
||||
`/reservations` (Admin UI) is the operator surface. It is **FleetAdmin-only**
|
||||
(the `CanPublish` policy).
|
||||
`/reservations` (Admin UI) is the operator surface. It requires authentication
|
||||
(`[Authorize]`) but is not restricted to a specific Admin UI role — any signed-in
|
||||
user can view it.
|
||||
|
||||
- **Active** table — every reservation with `ReleasedAt IS NULL`: kind, value,
|
||||
owning `EquipmentUuid`, cluster, and the first/last publish stamps. Each row
|
||||
has a **Release…** action.
|
||||
- **Released** table — the 100 most recently released reservations, with the
|
||||
releasing user and reason.
|
||||
- **Release dialog** — opened from an active row; it requires a reason before
|
||||
the Release button will submit, mirroring the procedure's audit invariant.
|
||||
The page is a **read-only flat list** of all `ExternalIdReservation` rows,
|
||||
ordered by Kind then Value. It shows Kind, Value, owning `EquipmentUuid`, and
|
||||
Cluster. There is no Active/Released split, no Release action, and no Release
|
||||
dialog on this page.
|
||||
|
||||
You cannot *create* a reservation from this page — reservations only ever come
|
||||
into existence as a side-effect of publishing a generation. The page is for
|
||||
inspection and for the release flow.
|
||||
into existence as a side-effect of publishing a generation. The release flow
|
||||
is described in `docs/v2/admin-ui.md` § "Release an external-ID reservation"
|
||||
and runs via `sp_ReleaseExternalIdReservation`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+18
-13
@@ -6,7 +6,7 @@ This file covers the engine internals — predicate evaluation, state machine, p
|
||||
|
||||
## Definition shape
|
||||
|
||||
`ScriptedAlarmDefinition` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `Phase7EngineComposer.ProjectScriptedAlarms`.
|
||||
`ScriptedAlarmDefinition` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `Phase7Composer.Compose` + the driver-role host actor startup path.
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
@@ -14,7 +14,7 @@ This file covers the engine internals — predicate evaluation, state machine, p
|
||||
| `EquipmentPath` | UNS path the alarm hangs under in the address space. ACL scope inherits from the equipment node. |
|
||||
| `AlarmName` | Browse-tree display name. |
|
||||
| `Kind` | `AlarmKind` — `AlarmCondition`, `LimitAlarm`, `DiscreteAlarm`, or `OffNormalAlarm`. Controls only the OPC UA ObjectType the node surfaces as; the internal state machine is identical for all four. |
|
||||
| `Severity` | `AlarmSeverity` enum (`Low` / `Medium` / `High` / `Critical`). Static per decision #13 — the predicate does not compute severity. The DB column is an OPC UA Part 9 1..1000 integer; `Phase7EngineComposer.MapSeverity` bands it into the four-value enum. |
|
||||
| `Severity` | `AlarmSeverity` enum (`Low` / `Medium` / `High` / `Critical`), defined in `Core.Abstractions/IAlarmSource.cs`. Static per decision #13 — the predicate does not compute severity. The publish path bands the configured value into this four-value enum before materialising the `ScriptedAlarmDefinition`. |
|
||||
| `MessageTemplate` | String with `{TagPath}` placeholders, resolved at emission time. See below. |
|
||||
| `PredicateScriptSource` | Roslyn C# script returning `bool`. `true` = condition active; `false` = cleared. |
|
||||
| `HistorizeToAveva` | When true, every emission is enqueued to `IAlarmHistorianSink`. Default true. Galaxy-native alarms default false since Galaxy historises them directly. |
|
||||
@@ -92,7 +92,7 @@ Predicate evaluation and message-template resolution deliberately treat tag-inpu
|
||||
|
||||
## State persistence
|
||||
|
||||
`IAlarmStateStore` (`IAlarmStateStore.cs`) is the persistence contract: `LoadAsync(alarmId)`, `LoadAllAsync`, `SaveAsync(state)`, `RemoveAsync(alarmId)`. `InMemoryAlarmStateStore` in the same file is the default for tests and dev deployments without a SQL backend. Stream E wires the production implementation against the `ScriptedAlarmState` config-DB table with audit logging through `Core.Abstractions.IAuditLogger`.
|
||||
`IAlarmStateStore` (`IAlarmStateStore.cs`) is the persistence contract: `LoadAsync(alarmId)`, `LoadAllAsync`, `SaveAsync(state)`, `RemoveAsync(alarmId)`. `InMemoryAlarmStateStore` in the same file is the default for tests and dev deployments without a SQL backend. The production implementation is `EfAlarmActorStateStore` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs`), which persists to the `ScriptedAlarmState` config-DB table via `IAlarmActorStateStore`.
|
||||
|
||||
Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelving`, `LastTransitionUtc`, the `LastAck*` / `LastConfirm*` audit fields, and the append-only `Comments` list. `Active` is **not** trusted across restart — the engine re-runs the predicate at `LoadAsync` so operators never re-ack an alarm that was already acknowledged before an outage, and alarms whose condition cleared during downtime settle to `Inactive` without a spurious clear-event.
|
||||
|
||||
@@ -111,15 +111,17 @@ Emissions map into `AlarmEventArgs` as `AlarmType = Kind.ToString()`, `SourceNod
|
||||
|
||||
## Composition
|
||||
|
||||
`Phase7EngineComposer.Compose` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs`) is the single call site that instantiates the engine. It takes the generation's `Script` / `VirtualTag` / `ScriptedAlarm` rows, the shared `CachedTagUpstreamSource`, an `IAlarmStateStore`, and an `IAlarmHistorianSink`, and returns a `Phase7ComposedSources` the caller owns. When `scriptedAlarms.Count > 0`:
|
||||
`Phase7Composer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`) is a pure data composer; it has no knowledge of `ScriptedAlarmEngine`. It maps `ScriptedAlarm` config-DB rows into `ScriptedAlarmPlan` records that the driver-role host actor startup path consumes.
|
||||
|
||||
1. `ProjectScriptedAlarms` resolves each row's `PredicateScriptId` against the script dictionary and produces a `ScriptedAlarmDefinition` list. Unknown or disabled scripts throw immediately — the DB publish guarantees referential integrity but this is a belt-and-braces check.
|
||||
2. A `ScriptedAlarmEngine` is constructed with the upstream source, the store, a shared `ScriptLoggerFactory` keyed to `scripts-*.log`, and the root Serilog logger.
|
||||
3. `alarmEngine.OnEvent` is wired to `RouteToHistorianAsync`, which projects each emission into an `AlarmHistorianEvent` and enqueues it on the sink. Fire-and-forget — the SQLite store-and-forward sink is already non-blocking.
|
||||
4. `LoadAsync(alarmDefs)` runs synchronously on the startup thread: it compiles every predicate, subscribes to the union of predicate inputs and message-template tokens, seeds the value cache, loads persisted state, re-derives `ActiveState` from a fresh predicate evaluation, and starts the 5s shelving timer. Compile failures are aggregated into one `InvalidOperationException` so operators see every bad predicate in one startup log line rather than one at a time.
|
||||
5. A `ScriptedAlarmSource` is created for the event stream, and a `ScriptedAlarmReadable` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs`) is created for OPC UA variable reads on the alarm's active-state node (task #245) — unknown alarm ids return `BadNodeIdUnknown` rather than silently reading `false`.
|
||||
In the v2 actor system, scripted-alarm engine composition is owned by the driver-role host:
|
||||
|
||||
Both engine and source are added to `Phase7ComposedSources.Disposables`, which `Phase7Composer` disposes on server shutdown.
|
||||
1. The host reads the generation's `ScriptedAlarm` + `Script` rows and resolves each row's `PredicateScriptId` to produce a `ScriptedAlarmDefinition` list. Unknown or disabled scripts fail fast — the DB publish guarantees referential integrity but this is a belt-and-braces check.
|
||||
2. A `ScriptedAlarmEngine` is constructed with the upstream-tag source, an `IAlarmStateStore` (production: `EfAlarmActorStateStore`), a shared `ScriptLoggerFactory` keyed to `scripts-*.log`, and the root Serilog logger.
|
||||
3. `alarmEngine.OnEvent` is wired to the historian sink. Fire-and-forget — the SQLite store-and-forward sink is already non-blocking.
|
||||
4. `LoadAsync(alarmDefs)` runs on startup: it compiles every predicate, subscribes to the union of predicate inputs and message-template tokens, seeds the value cache, loads persisted state, re-derives `ActiveState` from a fresh predicate evaluation, and starts the 5s shelving timer. Compile failures are aggregated into one `InvalidOperationException` so operators see every bad predicate in one startup log line rather than one at a time.
|
||||
5. A `ScriptedAlarmSource` is created for the event stream. The v2 `ScriptedAlarmActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs`) owns the active-state surface for OPC UA variable reads on the alarm's condition-state node — unknown alarm ids return `BadNodeIdUnknown` rather than silently reading `false`.
|
||||
|
||||
Both engine and source are disposed on server shutdown via the driver-role host teardown path.
|
||||
|
||||
## Key source files
|
||||
|
||||
@@ -129,8 +131,11 @@ Both engine and source are added to `Phase7ComposedSources.Disposables`, which `
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs` — pure-function state machine + `TransitionResult` / `EmissionKind`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs` — persisted state record + `AlarmComment` audit entry + `ShelvingState`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs` — script-side `ScriptContext` (read-only, write rejected)
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs` — `AlarmKind` + the four Part 9 enums
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs` — `AlarmKind` + `ShelvingKind` + four Part 9 state enums (`AlarmEnabledState`, `AlarmActiveState`, `AlarmAckedState`, `AlarmConfirmedState`); `AlarmSeverity` (`Low`/`Medium`/`High`/`Critical`) lives in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs` — `{path}` placeholder resolver
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs` — persistence contract + `InMemoryAlarmStateStore` default
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs` — composition, config-row projection, historian routing
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs` — `IReadable` adapter exposing `ActiveState` to OPC UA variable reads
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — pure data composer: config-DB entities → `Phase7CompositionResult` (UNS topology + driver/alarm plans)
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — applies the composed Phase 7 plan into the SDK node manager
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs` — actor that owns the per-alarm state machine; publishes `AlarmTransitionEvent` on the cluster `alerts` DPS topic
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs` — production `IAlarmActorStateStore` backed by the `ScriptedAlarmState` config-DB table
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs` — production Roslyn predicate evaluator
|
||||
|
||||
+14
-4
@@ -7,7 +7,7 @@ A production OtOpcUa deployment runs **one binary per node**, plus the optional
|
||||
| Process | Project | Runtime | Platform | Responsibility |
|
||||
|---|---|---|---|---|
|
||||
| **OtOpcUa Host** | `src/Server/ZB.MOM.WW.OtOpcUa.Host` | .NET 10 | AnyCPU | Single fused binary. `OTOPCUA_ROLES` env decides what to mount: `admin` (Blazor + auth + control-plane singletons), `driver` (OPC UA endpoint + per-driver actors), or both. |
|
||||
| **OtOpcUa Wonderware Historian** *(optional)* | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware` | .NET Framework 4.8 | x86 (32-bit) | Out-of-process sidecar exposing the Wonderware Historian SDK over a named pipe. Required only when `Historian:Wonderware:Enabled=true`. |
|
||||
| **OtOpcUa Wonderware Historian** *(optional)* | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware` | .NET Framework 4.8 | x64 (64-bit) | Out-of-process sidecar exposing the Wonderware Historian SDK over a named pipe. Required only when `Historian:Wonderware:Enabled=true`. |
|
||||
|
||||
Galaxy access still uses the separately-installed **mxaccessgw** sidecar (see `docs/v2/Galaxy.ParityRig.md`); the gateway owns the MXAccess COM bitness constraint (its worker is x86 net48). Nothing in the OtOpcUa repo carries that constraint anymore.
|
||||
|
||||
@@ -25,6 +25,16 @@ Galaxy access still uses the separately-installed **mxaccessgw** sidecar (see `d
|
||||
|
||||
Single-node dev: `OTOPCUA_ROLES=admin,driver`. Production: typically two admin nodes (HA pair) + N driver nodes.
|
||||
|
||||
### Per-role configuration overlays
|
||||
|
||||
`Program.cs:33-35` builds a role suffix by joining the parsed roles **alphabetically** with `-` and loads `appsettings.{roleSuffix}.json` as an optional overlay on top of base `appsettings.json`. Three overlays ship in `src/Server/ZB.MOM.WW.OtOpcUa.Host/`:
|
||||
|
||||
- `appsettings.admin.json` — admin-only nodes
|
||||
- `appsettings.driver.json` — driver-only nodes
|
||||
- `appsettings.admin-driver.json` — fused single-node dev / small deployments
|
||||
|
||||
All three carry Serilog log-level overrides + `Security:Ldap:DevStubMode = false`. Loading order is **base `appsettings.json` → role overlay (`appsettings.{role}.json`) → environment overlay (`appsettings.{Environment}.json`)** — later layers win. Overlays are optional; the base file boots a node on its own.
|
||||
|
||||
## Akka cluster
|
||||
|
||||
The host joins an Akka.NET cluster bound to the address in `appsettings.json::Cluster`:
|
||||
@@ -56,15 +66,15 @@ Both admin and driver nodes expose:
|
||||
| `/health/ready` | ConfigDb reachable + cluster member state is `Up`. |
|
||||
| `/health/active` | Admin-role leader (the node Traefik or an HA LB should route traffic to). |
|
||||
|
||||
Used by Traefik for the active-leader-only routing pattern (see [Task 63 traefik docs](v2/Architecture-v2.md) — TODO).
|
||||
Used by Traefik for the active-leader-only routing pattern (see [Architecture-v2.md](v2/Architecture-v2.md)).
|
||||
|
||||
## OtOpcUa Wonderware Historian (optional)
|
||||
|
||||
Unchanged from v1. Pipe IPC contract lives in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Contracts/`; sidecar pipe handler in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Pipe/`. Install via `scripts/install/Install-Services.ps1 -InstallWonderwareHistorian`.
|
||||
Unchanged from v1. IPC contract types live in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/`; sidecar pipe handler in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/`. Install via `scripts/install/Install-Services.ps1 -InstallWonderwareHistorian`.
|
||||
|
||||
## Install / Uninstall
|
||||
|
||||
- `scripts/install/Install-Services.ps1 -Roles admin,driver` — installs `OtOpcUaHost`. v2 rewrite tracked as plan Task 62.
|
||||
- `scripts/install/Install-Services.ps1 -Roles admin,driver` — installs `OtOpcUaHost`.
|
||||
- `scripts/install/Uninstall-Services.ps1` — stops + removes the host service (and the historian sidecar if installed).
|
||||
|
||||
## Logging
|
||||
|
||||
+31
-29
@@ -1,8 +1,8 @@
|
||||
# Virtual Tags
|
||||
|
||||
Virtual tags are OPC UA variable nodes whose values are computed by operator-authored C# scripts against other tags (driver or virtual). They live in the Equipment browse tree alongside driver-sourced variables: a client browsing `Enterprise/Site/Area/Line/Equipment/` sees one flat child list that mixes both kinds, and a read / subscribe on a virtual node looks identical to one on a driver node from the wire. The separation is server-side — `NodeScopeResolver` tags each variable's `NodeSource` (`Driver` / `Virtual` / `ScriptedAlarm`), and `DriverNodeManager` dispatches reads to different backends accordingly. See [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) for the dispatch decision.
|
||||
Virtual tags are OPC UA variable nodes whose values are computed by operator-authored C# scripts against other tags (driver or virtual). They live in the Equipment browse tree alongside driver-sourced variables: a client browsing `Enterprise/Site/Area/Line/Equipment/` sees one flat child list that mixes both kinds, and a read / subscribe on a virtual node looks identical to one on a driver node from the wire. The separation is server-side — `EquipmentNodeWalker` stamps each `DriverAttributeInfo` with `NodeSourceKind` (`Driver` / `Virtual` / `ScriptedAlarm`) at address-space build time, and `GenericDriverNodeManager` routes reads to different backends accordingly. See [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) for the dispatch decision.
|
||||
|
||||
The runtime is split across two projects: `Core.Scripting` holds the Roslyn sandbox + evaluator primitives that are reused by both virtual tags and scripted alarms; `Core.VirtualTags` holds the engine that owns the dependency graph, the evaluation pipeline, and the `ISubscribable` adapter the server dispatches to.
|
||||
The runtime is split across two projects: `Core.Scripting` holds the Roslyn sandbox + evaluator primitives that are reused by both virtual tags and scripted alarms; `Core.VirtualTags` holds the engine that owns the dependency graph, the evaluation pipeline, and the `ISubscribable` adapter the server dispatches to. In the v2 actor system, `VirtualTagActor` + `DependencyMuxActor` (in `Core.Runtime`) own the per-instance state and upstream-feed wiring; `RoslynVirtualTagEvaluator` (in `Host.Engines`) is the production `IVirtualTagEvaluator` binding.
|
||||
|
||||
## Roslyn script sandbox (`Core.Scripting`)
|
||||
|
||||
@@ -10,15 +10,19 @@ User scripts are compiled via `Microsoft.CodeAnalysis.CSharp` (regular compiler,
|
||||
|
||||
### Compile pipeline (`ScriptEvaluator<TContext, TResult>`)
|
||||
|
||||
`ScriptEvaluator.Compile(source)` is a three-step gate:
|
||||
`ScriptEvaluator.Compile(source)` is a five-step gate:
|
||||
|
||||
1. **Roslyn compile** against `ScriptSandbox.Build(contextType)`. Throws `CompilationErrorException` on syntax / type errors.
|
||||
2. **`ForbiddenTypeAnalyzer.Analyze`** walks the syntax tree post-compile and resolves every referenced symbol against the deny-list. Throws `ScriptSandboxViolationException` with every offending source span attached. This is defence-in-depth: `ScriptOptions` alone cannot block every BCL namespace because .NET type forwarding routes types through assemblies the allow-list does permit.
|
||||
3. **Delegate materialization** — `script.CreateDelegate()`. Failures here are Roslyn-internal; user scripts don't reach this step.
|
||||
1. **Injection guard** — `EnforceSingleRunMember` parses the synthesized wrapper and rejects sources whose brace structure would inject sibling methods or type declarations alongside the `CompiledScript.Run` wrapper method. Throws `CompilationErrorException` with diagnostic id `LMX001`/`LMX002` (Core.Scripting-013).
|
||||
2. **Roslyn compile** against `ScriptSandbox.Build(contextType)`. Throws `CompilationErrorException` on syntax / type errors.
|
||||
3. **`ForbiddenTypeAnalyzer.Analyze`** walks the syntax tree post-compile and resolves every referenced symbol against the deny-list. Throws `ScriptSandboxViolationException` with every offending source span attached. This is defence-in-depth: `ScriptOptions` alone cannot block every BCL namespace because .NET type forwarding routes types through assemblies the allow-list does permit.
|
||||
4. **PE emit** — `CSharpCompilation.Emit` writes the assembly to a `MemoryStream`. Failures here are Roslyn-internal; user scripts don't reach this step.
|
||||
5. **ALC load + delegate bind** — loads the emitted assembly into a collectible `ScriptAssemblyLoadContext` and binds a typed `Func<ScriptGlobals<TContext>, TResult>` delegate to the `CompiledScript.Run` method.
|
||||
|
||||
`ScriptSandbox.Build` allow-lists exactly: `System.Private.CoreLib` (primitives + `Math` + `Convert`), `System.Linq`, `Core.Abstractions` (for `DataValueSnapshot` / `DriverDataType`), `Core.Scripting` (for `ScriptContext` + `Deadband`), `Serilog` (for `ILogger`), and the concrete context type's assembly. Pre-imported namespaces: `System`, `System.Linq`, `ZB.MOM.WW.OtOpcUa.Core.Abstractions`, `ZB.MOM.WW.OtOpcUa.Core.Scripting`.
|
||||
`ScriptSandbox.Build` constructs the compile reference set in two parts. First, four pinned OtOpcUa assemblies are always included: `Core.Abstractions` (for `DataValueSnapshot` / `DriverDataType`), `Core.Scripting` (for `ScriptContext` + `Deadband`), `Serilog` (for `ILogger`), and the concrete context type's assembly. Second, the BCL subset is enumerated from the runtime's `TRUSTED_PLATFORM_ASSEMBLIES` list, restricted to filenames starting with `System.*` plus `netstandard.dll`, `mscorlib.dll`, and `Microsoft.Win32.Registry.dll` (the last needed so `ForbiddenTypeAnalyzer` can resolve and reject registry types). Pre-imported namespaces: `System`, `System.Linq`, `ZB.MOM.WW.OtOpcUa.Core.Abstractions`, `ZB.MOM.WW.OtOpcUa.Core.Scripting`.
|
||||
|
||||
`ForbiddenTypeAnalyzer.ForbiddenNamespacePrefixes` currently denies `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`, `System.Threading.Thread`, `System.Threading.Tasks`, `System.Runtime.InteropServices`, `Microsoft.Win32`. Matching is by prefix against the resolved symbol's containing namespace, so `System.Net` catches `System.Net.Http.HttpClient` and every subnamespace. `System.Threading.Tasks` is denied because scripts are synchronous predicates with no legitimate need to start background tasks — a `Task.Run` fan-out would outlive the per-evaluation timeout entirely (Core.Scripting-003). `System.Environment`, `System.AppDomain`, `System.GC`, and `System.Activator` are denied type-granularly via `ForbiddenFullTypeNames` because they live directly in the `System` namespace (which is otherwise allowed for primitives) — `Environment.Exit` / `FailFast` terminate the host process outright (Core.Scripting-001).
|
||||
`ForbiddenTypeAnalyzer.ForbiddenNamespacePrefixes` denies `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`, `System.Threading.Tasks`, `System.Runtime.InteropServices`, `System.Runtime.Loader`, and `Microsoft.Win32`. Matching is by prefix against the resolved symbol's containing namespace, so `System.Net` catches `System.Net.Http.HttpClient` and every subnamespace. `System.Threading.Tasks` is denied because scripts are synchronous predicates with no legitimate need to start background tasks — a `Task.Run` fan-out would outlive the per-evaluation timeout entirely (Core.Scripting-003). `System.Runtime.Loader` is denied to block `AssemblyLoadContext` / `AssemblyDependencyResolver` — arbitrary DLL loads into the host process (Core.Scripting-012).
|
||||
|
||||
`ForbiddenTypeAnalyzer.ForbiddenFullTypeNames` denies type-granularly: `System.Environment`, `System.AppDomain`, `System.GC`, `System.Activator`, `System.Threading.Thread`, `System.Threading.ThreadPool`, and `System.Threading.Timer`. These types require granular denial rather than namespace-prefix denial for different reasons: `Environment` / `AppDomain` / `GC` / `Activator` live directly in the `System` namespace (which is otherwise allowed for primitives), so a namespace-prefix rule cannot reach them without blocking `int` / `string` / `Math`; `Thread` / `ThreadPool` / `Timer` live in `System.Threading` (shared with allowed types like `CancellationToken` and `SemaphoreSlim`), so a prefix on `System.Threading` would block those too. `Environment.Exit` / `FailFast` terminate the host process outright (Core.Scripting-001); `Thread` and `ThreadPool` reintroduce background-fanout vectors that `System.Threading.Tasks` denial closed (Core.Scripting-010 / -012).
|
||||
|
||||
#### Known resource limits (accepted trade-offs)
|
||||
|
||||
@@ -94,36 +98,33 @@ Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize
|
||||
|
||||
## Dispatch integration
|
||||
|
||||
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B, there is a single `DriverNodeManager`. `VirtualTagSource` implements `IReadable` + `ISubscribable` over a `VirtualTagEngine`:
|
||||
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B, there is a single `GenericDriverNodeManager`. `VirtualTagSource` implements `IReadable` + `ISubscribable` over a `VirtualTagEngine`:
|
||||
|
||||
- `ReadAsync` fans each path through `engine.Read(...)`.
|
||||
- `SubscribeAsync` calls `engine.Subscribe` per path and forwards each engine observer callback as an `OnDataChange` event; emits an initial-data callback per OPC UA convention.
|
||||
- `UnsubscribeAsync` disposes every per-path engine subscription it holds.
|
||||
- **`IWritable` is deliberately not implemented.** `DriverNodeManager.IsWriteAllowedBySource` rejects OPC UA client writes to virtual nodes with `BadUserAccessDenied` before any dispatch — scripts are the only write path via `ctx.SetVirtualTag`.
|
||||
- **`IWritable` is deliberately not implemented.** Virtual-tag nodes are not client-writable because `OtOpcUaNodeManager.EnsureVariable` materialises every SDK variable with `AccessLevel = AccessLevels.CurrentRead`; the SDK base `CustomNodeManager2.Write` returns `BadNotWritable` for read-only nodes and v2 has no client-write dispatch path. Scripts are the only write path via `ctx.SetVirtualTag`.
|
||||
|
||||
`DriverNodeManager.SelectReadable(source, ...)` picks the `IReadable` based on `NodeSourceKind`. See [ReadWriteOperations.md](ReadWriteOperations.md) and [Subscriptions.md](Subscriptions.md) for the broader dispatch framing.
|
||||
`NodeSourceKind` on each `DriverAttributeInfo` (set by `EquipmentNodeWalker` at address-space build time) drives which backend handles a read. See [ReadWriteOperations.md](ReadWriteOperations.md) and [v1/Subscriptions.md](v1/Subscriptions.md) for the broader dispatch framing.
|
||||
|
||||
## Upstream reads + history
|
||||
|
||||
`ITagUpstreamSource` and `IHistoryWriter` are the two ports the engine requires from its host. Both live in `Core.VirtualTags`. In the Server process:
|
||||
`ITagUpstreamSource` and `IHistoryWriter` are the two ports the engine requires from its host. Both live in `Core.VirtualTags`. In the v2 actor system:
|
||||
|
||||
- **`CachedTagUpstreamSource`** (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/CachedTagUpstreamSource.cs`) implements the interface (and the parallel `Core.ScriptedAlarms.ITagUpstreamSource` — identical shape, distinct namespace). A `ConcurrentDictionary<path, DataValueSnapshot>` cache. `Push(path, snapshot)` updates the cache and fans out synchronously to every observer. Reads of never-pushed paths return `BadNodeIdUnknown` quality (`UpstreamNotConfigured = 0x80340000`).
|
||||
- **`DriverSubscriptionBridge`** (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs`) feeds the cache. For each registered `ISubscribable` driver it batches a single `SubscribeAsync` for every fullRef the script graph references, installs an `OnDataChange` handler that translates driver-opaque fullRefs back to UNS paths via a reverse map, and pushes each delta into `CachedTagUpstreamSource`. Unsubscribes on dispose. The bridge suppresses `OTOPCUA0001` (the Roslyn analyzer that requires `ISubscribable` callers to go through `CapabilityInvoker`) on the documented basis that this is a lifecycle wiring, not per-evaluation hot path.
|
||||
- **`IHistoryWriter`** — no production implementation is currently wired for virtual tags; `VirtualTagEngine` gets `NullHistoryWriter` by default from `Phase7EngineComposer`.
|
||||
- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes `DriverInstanceActor.AttributeValuePublished` events to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot.
|
||||
- **`IHistoryWriter`** — no production implementation is wired for virtual tags; `VirtualTagEngine` receives `NullHistoryWriter` by default.
|
||||
|
||||
## Composition
|
||||
|
||||
`Phase7Composer` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs`) is an `IAsyncDisposable` injected into `OpcUaServerService`:
|
||||
`Phase7Composer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`) is a pure static function that flattens config-DB entities into a `Phase7CompositionResult` value (UNS topology + driver-instance plans + scripted-alarm plans). `Phase7Applier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `VirtualTagEngine` or `ScriptedAlarmEngine`.
|
||||
|
||||
1. `PrepareAsync(generationId, ct)` — called after the bootstrap generation loads and before `OpcUaApplicationHost.StartAsync`. Reads the `Script` / `VirtualTag` / `ScriptedAlarm` rows for that generation from the config DB (`OtOpcUaConfigDbContext`). Empty-config fast path returns `Phase7ComposedSources.Empty`.
|
||||
2. Constructs a `CachedTagUpstreamSource` + hands it to `Phase7EngineComposer.Compose`.
|
||||
3. `Phase7EngineComposer.Compose` projects `VirtualTag` rows into `VirtualTagDefinition`s (joining `Script` rows by `ScriptId`), instantiates `VirtualTagEngine`, calls `Load`, wraps in `VirtualTagSource`.
|
||||
4. Builds a `DriverFeed` per driver by mapping the driver's `EquipmentNamespaceContent` to `UNS path → driver fullRef` (path format `/{area}/{line}/{equipment}/{tag}` matching the `EquipmentNodeWalker` browse tree so script literals match the operator-visible UNS), then starts `DriverSubscriptionBridge`.
|
||||
5. Returns `Phase7ComposedSources` with the `VirtualTagSource` cast as `IReadable`. `OpcUaServerService` passes it to `OpcUaApplicationHost` which threads it into `DriverNodeManager` as `virtualReadable`.
|
||||
In the v2 actor system, virtual-tag engine composition is owned by the driver-role host actor tree:
|
||||
|
||||
`DisposeAsync` tears down the bridge first (no more events into the cache), then the engines (cascades + timer ticks stop), then the owned SQLite historian sink if any.
|
||||
- `Phase7Composer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm.
|
||||
- `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use.
|
||||
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref.
|
||||
|
||||
Definition reload on config publish: `VirtualTagEngine.Load` is re-entrant — a future config-publish handler can call it with a new definition set. That handler is not yet wired; today engine composition happens once per service start against the bootstrapped generation.
|
||||
`VirtualTagEngine`, `VirtualTagSource`, `TimerTriggerScheduler`, and `ITagUpstreamSource` are available as standalone Core.VirtualTags primitives and remain the correct composition path for non-actor deployments (integration tests, future standalone runtimes).
|
||||
|
||||
## Key source files
|
||||
|
||||
@@ -131,7 +132,7 @@ Definition reload on config publish: `VirtualTagEngine.Load` is re-entrant — a
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs` — generic globals wrapper naming the field `ctx`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs` — assembly allow-list + imports
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs` — post-compile semantic deny-list
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs` — three-step compile pipeline
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs` — five-step compile pipeline (injection guard → Roslyn compile → ForbiddenTypeAnalyzer → PE emit → ALC load)
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs` — 250ms default timeout wrapper
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs` — SHA-256-keyed compile cache
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs` — static `ctx.GetTag` / `ctx.SetVirtualTag` inference
|
||||
@@ -145,8 +146,9 @@ Definition reload on config publish: `VirtualTagEngine.Load` is re-entrant — a
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs` — driver-tag read + subscribe port
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs` — historize sink port + `NullHistoryWriter`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs` — `IReadable` + `ISubscribable` adapter
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/CachedTagUpstreamSource.cs` — production `ITagUpstreamSource`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs` — driver `ISubscribable` → cache feed
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs` — row projection + engine instantiation
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs` — lifecycle host: load rows, compose, wire bridge
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `SelectReadable` + `IsWriteAllowedBySource` dispatch kernel
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — pure data composer: config-DB entities → `Phase7CompositionResult` (UNS topology + driver/alarm plans)
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — applies the composed plan into the SDK node manager
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — driver-agnostic OPC UA node-manager backbone; per-variable `NodeSourceKind` drives dispatch
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# AB CIP Driver
|
||||
|
||||
In-process native-protocol driver that exposes Allen-Bradley CIP / EtherNet-IP
|
||||
controllers as OPC UA nodes. It runs inside the OtOpcUa server's .NET 10 AnyCPU
|
||||
process and talks to the PLC through the libplctag.NET wrapper — no gateway, no
|
||||
sidecar. One driver instance can serve many devices; per-device routing is keyed
|
||||
on the canonical `ab://gateway[:port]/cip-path` host-address string.
|
||||
|
||||
Supported families: **ControlLogix**, **CompactLogix**, **Micro800**, and
|
||||
**GuardLogix**. CIP has no native push model, so subscriptions are a polling
|
||||
overlay on top of `IReadable`.
|
||||
|
||||
For the driver spec (capability surface, config shape, type mapping), see
|
||||
[docs/v2/driver-specs.md §3](../v2/driver-specs.md). For the manual test client,
|
||||
see [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md). For the integration fixture
|
||||
coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
|
||||
|
||||
## Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
|
||||
|
||||
Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`)
|
||||
supplies the libplctag `plc` attribute, default CIP path, ConnectionSize, and
|
||||
request-packing / connected-messaging quirks — ControlLogix is the baseline and
|
||||
each other family is a delta (Micro800 is unconnected-only with no backplane
|
||||
routing; GuardLogix shares the ControlLogix wire protocol with a tag-level safety
|
||||
partition).
|
||||
|
||||
## Capability Surface
|
||||
|
||||
`AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable`
|
||||
(`Driver.AbCip/AbCipDriver.cs`). It adds **`IAlarmSource`** over the Modbus /
|
||||
AB Legacy surface.
|
||||
|
||||
| Capability | Implementation entry point | Notes |
|
||||
|------------|---------------------------|-------|
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders; UDT tags with declared `Members` fan out into a sub-folder + one variable per member. With `EnableControllerBrowse` the `@tags` symbol table is walked into a `Discovered/` folder (system/module/routine tags filtered out). |
|
||||
| `IReadable` | `ReadAsync` → `ReadGroupAsync` / `ReadSingleAsync` | Per-tag reads; opt-in whole-UDT grouping (`EnableDeclarationOnlyUdtGrouping`) collapses N member reads into one. |
|
||||
| `IWritable` | `WriteAsync` | BOOL-within-DINT writes do a per-parent read-modify-write under a lock; `SafetyTag` and non-writable tags return `BadNotWritable`. |
|
||||
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | CIP has no push model — subscriptions become polling groups. |
|
||||
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeTagPath`; no path configured ⇒ a warning is logged and the device stays `Unknown`. |
|
||||
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`, the breaker key for the resilience pipeline so one dead PLC trips only its own breaker. |
|
||||
| `IAlarmSource` | `AbCipAlarmProjection` (ALMD) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. |
|
||||
|
||||
## Addressing Model
|
||||
|
||||
Per-device host addresses are the canonical `ab://gateway[:port]/cip-path` form
|
||||
parsed by `AbCipHostAddress.TryParse` (`AbCipHostAddress.cs`). The parsed
|
||||
`CipPath` is handed to libplctag verbatim, so no wire-layer translation is
|
||||
needed:
|
||||
|
||||
| Form | Meaning |
|
||||
|------|---------|
|
||||
| `ab://10.0.0.5/1,0` | Single-chassis ControlLogix, CPU in slot 0 |
|
||||
| `ab://10.0.0.5/1,2,2,192.168.50.20,1,0` | Bridged ControlLogix (routed path) |
|
||||
| `ab://10.0.0.5/` | Micro800 / no-backplane device (empty path) |
|
||||
| `ab://10.0.0.5:44818/1,0` | Explicit EIP port (default 44818) |
|
||||
|
||||
Tags carry a Logix symbolic `TagPath` (controller or program scope). UDT-typed
|
||||
tags are declared as `AbCipDataType.Structure` with a `Members` list; discovery
|
||||
fans each member out as `{tag.Name}.{member.Name}`, and the read planner can
|
||||
collapse a batch of members into one whole-UDT read when
|
||||
`EnableDeclarationOnlyUdtGrouping` is set. The whole-UDT fast path is opt-in
|
||||
because Studio 5000 may reorder members vs declaration order; decoding at
|
||||
declaration-order offsets against a reordered layout yields silently-plausible
|
||||
wrong numbers.
|
||||
|
||||
## Configuration
|
||||
|
||||
`AbCipDriverOptions` (`Driver.AbCip.Contracts/AbCipDriverOptions.cs`) binds from
|
||||
the driver's `DriverConfig` JSON. Key fields:
|
||||
|
||||
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
|
||||
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
|
||||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
|
||||
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
|
||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
|
||||
|
||||
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
|
||||
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
|
||||
|
||||
## Alarm Projection
|
||||
|
||||
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
|
||||
ALMD UDT's `InFaulted` + `Severity` members at `AlarmPollInterval` and fires
|
||||
`OnAlarmEvent` on raise/clear transitions. It is **ALMD-only** in this pass (ALMA
|
||||
analog alarms are a follow-up) and **disabled by default** — shops running FT
|
||||
Alarm & Events should keep it off and take alarms through the native route, since
|
||||
the projection semantics don't exactly mirror Rockwell FT A&E.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes.
|
||||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` run against the `ab_server` Docker fixture. See [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md) for the coverage map and the `AB_SERVER_ENDPOINT` wiring.
|
||||
- **Manual client** — [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md).
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- **Native heap is invisible to the GC.** `GetMemoryFootprint()` reports CLR allocations only; libplctag's native `Tag` heap does not show up there. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth.
|
||||
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
|
||||
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
|
||||
@@ -6,26 +6,26 @@ MicroLogix / PLC-5 / LogixPccc-mode.
|
||||
**TL;DR:** Docker integration-test scaffolding lives at
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` (task #224),
|
||||
reusing the AB CIP `ab_server` image in PCCC mode with per-family
|
||||
compose profiles (`slc500` / `micrologix` / `plc5`). Scaffold passes
|
||||
the skip-when-absent contract cleanly. **Wire-level round-trip against
|
||||
`ab_server` PCCC mode currently fails** with `BadCommunicationError`
|
||||
on read/write (verified 2026-04-20) — ab_server's PCCC server-side
|
||||
coverage is narrower than libplctag's PCCC client expects. The smoke
|
||||
tests target the correct shape for real hardware + should pass when
|
||||
`AB_LEGACY_ENDPOINT` points at a real SLC 5/05 / MicroLogix. Unit tests
|
||||
via `FakeAbLegacyTag` still carry the contract coverage.
|
||||
compose profiles (`slc500` / `micrologix` / `plc5`). The smoke tests pass
|
||||
for N-file (Int16), F-file (Float32), and L-file (Int32) reads across all
|
||||
three families when `AB_LEGACY_CIP_PATH=1,0` (the default). The earlier
|
||||
`BadCommunicationError` was traced to `ab_server` requiring a non-empty CIP
|
||||
routing path before forwarding to the PCCC dispatcher — the `/1,0` workaround
|
||||
resolves it (see `Docker/README.md §Known limitations`). Residual gap: bit-file
|
||||
writes (`B3:0/5`) still surface `0x803D0000` against `ab_server`. Unit tests
|
||||
via `FakeAbLegacyTag` carry full contract coverage for all paths.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #224, scaffolded with a known ab_server
|
||||
gap):
|
||||
**Integration layer** (task #224):
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` with
|
||||
`AbLegacyServerFixture` (TCP-probes `localhost:44818`) + three smoke
|
||||
tests (parametric read across families, SLC500 write-then-read). Reuses
|
||||
the AB CIP `otopcua-ab-server:libplctag-release` image via a relative
|
||||
`build:` context in `Docker/docker-compose.yml` — one image, different
|
||||
`--plc` flags. See `Docker/README.md` §Known limitations for the
|
||||
ab_server PCCC round-trip gap + resolution paths.
|
||||
`AbLegacyServerFixture` (TCP-probes `10.100.0.35:44818` — the shared Docker
|
||||
host; override with `AB_LEGACY_ENDPOINT`) + three smoke tests (parametric read
|
||||
across families, SLC500 write-then-read). Reuses the AB CIP
|
||||
`otopcua-ab-server:libplctag-release` image via a relative `build:` context in
|
||||
`Docker/docker-compose.yml` — one image, different `--plc` flags. See
|
||||
`Docker/README.md §Known limitations` for the CIP-path gate + bit-file write
|
||||
gap.
|
||||
|
||||
**Unit layer**: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` is
|
||||
still the primary coverage. All tests tagged `[Trait("Category", "Unit")]`.
|
||||
@@ -93,13 +93,12 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **Expand ab_server PCCC coverage** — the smoke suite passes today
|
||||
for N (Int16), F (Float32), and L (Int32) files across SLC500 /
|
||||
MicroLogix / PLC-5 modes with the `/1,0` cip-path workaround in
|
||||
place. Known residual gap: bit-file writes (`B3:0/5`) surface
|
||||
`0x803D0000`. Contributing a patch to `libplctag/libplctag` to close
|
||||
this + documenting ab_server's empty-path rejection in its README
|
||||
would remove the last Docker-vs-hardware divergences.
|
||||
1. **Close residual ab_server bit-file write gap** — N (Int16), F (Float32),
|
||||
and L (Int32) files round-trip cleanly across SLC500 / MicroLogix / PLC-5
|
||||
modes with the `/1,0` cip-path workaround in place. Remaining gap: bit-file
|
||||
writes (`B3:0/5`) surface `0x803D0000` against `ab_server --plc=SLC500`.
|
||||
Contributing a patch to `libplctag/libplctag` to close this would remove
|
||||
the last Docker-vs-hardware divergence for bit writes.
|
||||
2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator
|
||||
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
|
||||
indirection), timer/counter decomposition, and real ladder execution
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# AB Legacy Driver
|
||||
|
||||
In-process native-protocol driver that exposes legacy Allen-Bradley PLCs —
|
||||
**SLC 500**, **MicroLogix**, **PLC-5**, and Logix-via-PCCC — as OPC UA nodes. It
|
||||
runs inside the OtOpcUa server's .NET 10 AnyCPU process and speaks PCCC over
|
||||
EtherNet/IP through the same libplctag.NET wrapper as the AB CIP driver, but
|
||||
addresses data by **file** (data-table) rather than by symbolic tag. One driver
|
||||
instance can serve many devices; per-device routing is keyed on the canonical
|
||||
`ab://gateway[:port]/cip-path` host-address string. PCCC has no native push
|
||||
model, so subscriptions are a polling overlay on top of `IReadable`.
|
||||
|
||||
For the driver spec (capability surface, config shape, payload limits), see
|
||||
[docs/v2/driver-specs.md §4](../v2/driver-specs.md). For the manual test client,
|
||||
see [Driver.AbLegacy.Cli.md](../Driver.AbLegacy.Cli.md). For the integration
|
||||
fixture coverage map, see [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md).
|
||||
|
||||
## Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/` | The driver — `AbLegacyDriver`, the libplctag runtime wrapper, the PCCC file-address parser (`AbLegacyAddress`), the host-address parser, and the status mapper. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/` | `AbLegacyDriverOptions`, `AbLegacyDeviceOptions`, `AbLegacyTagDefinition`, and the `AbLegacyDataType` / `AbLegacyPlcFamily` / `AbLegacyPlcFamilyProfile` records bound from the driver's `DriverConfig` JSON. |
|
||||
|
||||
Per family the `AbLegacyPlcFamilyProfile` supplies the libplctag `plc` attribute,
|
||||
default CIP path, max-payload bytes, and the `SupportsStringFile` /
|
||||
`SupportsLongFile` capability flags. MicroLogix uses direct EIP (empty default
|
||||
path); MicroLogix and PLC-5 don't ship L-files; PLC-5 predates them entirely.
|
||||
Tag types are validated against the device's profile at init time — declaring a
|
||||
`Long` or `String` tag on a family that can't support it fails fast with a clear
|
||||
message.
|
||||
|
||||
## Capability Surface
|
||||
|
||||
`AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable`
|
||||
(`Driver.AbLegacy/AbLegacyDriver.cs`). There is **no `IAlarmSource`** — unlike the
|
||||
AB CIP driver, PCCC has no ALMD instruction to project, so alarms are out of
|
||||
scope.
|
||||
|
||||
| Capability | Implementation entry point | Notes |
|
||||
|------------|---------------------------|-------|
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders. Tags are single-element today (`IsArray` hard-wired false); multi-element file ranges are a tracked follow-up. |
|
||||
| `IReadable` | `ReadAsync` | Per-tag reads serialized per cached runtime under a lock (a libplctag `Tag` handle is not concurrency-safe across the server read path + poll loop). |
|
||||
| `IWritable` | `WriteAsync` | Bit-within-word writes (N-file `N7:0/3`, B-file bits) do a per-parent-word read-modify-write under a lock. Non-writable tags return `BadNotWritable`. |
|
||||
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | No push model — subscriptions become polling groups. |
|
||||
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeAddress`; transitions log Warning (down) / Information (recover). |
|
||||
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`; unknown references fall back to the first device, never throwing (per the interface contract). |
|
||||
|
||||
## Addressing Model
|
||||
|
||||
Per-device host addresses are the canonical `ab://gateway[:port]/cip-path` form
|
||||
parsed by `AbLegacyHostAddress.TryParse`. When the parsed CIP path is empty the
|
||||
family profile's default path is used (e.g. SLC 500 gets `1,0`; MicroLogix stays
|
||||
empty for direct EIP).
|
||||
|
||||
Tags carry a PCCC **file address** parsed by `AbLegacyAddress` (`AbLegacyAddress.cs`)
|
||||
— file letter + file number + word number, with an optional bit index (`/N`) or
|
||||
structured sub-element (`.ACC`, `.PRE`, …). The string is passed straight through
|
||||
to libplctag's `name=` attribute; the parser validates shape and surfaces the
|
||||
pieces for driver-side routing (e.g. deciding a bit needs read-modify-write):
|
||||
|
||||
| Form | Meaning |
|
||||
|------|---------|
|
||||
| `N7:0` | Integer file 7, word 0 (signed 16-bit) |
|
||||
| `F8:0` | Float file 8, word 0 (32-bit IEEE-754) |
|
||||
| `B3:0/0` | Bit file 3, word 0, bit 0 |
|
||||
| `L9:0` | Long-integer file (SLC 5/05+, 32-bit) |
|
||||
| `ST9:0` | String file (82-byte fixed-length) |
|
||||
| `T4:0.ACC` / `C5:0.PRE` | Timer / counter sub-element |
|
||||
| `I:0/0` / `O:1/2` / `S:1` | Input / output / status system files (no file number) |
|
||||
|
||||
`AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long`
|
||||
(L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` /
|
||||
`CounterElement` / `ControlElement` sub-element families. The parser enforces
|
||||
PCCC structural rules — bit-addressing only on 16/32-bit element files,
|
||||
sub-elements only on T/C/R files, no file number on I/O/S — rejecting malformed
|
||||
addresses before they reach libplctag.
|
||||
|
||||
## Configuration
|
||||
|
||||
`AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`)
|
||||
binds from the driver's `DriverConfig` JSON:
|
||||
|
||||
- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`).
|
||||
- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`).
|
||||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`.
|
||||
|
||||
Full per-field descriptions live in the contracts assembly. The JSON skeleton is
|
||||
reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes.
|
||||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` run against the AB Legacy Docker fixture. See [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md) for the coverage map.
|
||||
- **Manual client** — [Driver.AbLegacy.Cli.md](../Driver.AbLegacy.Cli.md).
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- **Native heap is invisible to the GC.** As with AB CIP, `GetMemoryFootprint()` reports CLR allocations only; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles.
|
||||
- **PCCC reconnect is more expensive than CIP** — legacy PLCs have no connection multiplexing, so the resilience pipeline should use longer backoff than for AB CIP (see [docs/v2/driver-specs.md §4](../v2/driver-specs.md)).
|
||||
- **Single-element addressing today** — a PCCC file is inherently an array (an N7 file is up to 256 words), but the current tag surface addresses one element per tag; range-spanning tags must be enumerated element-by-element until multi-element addressing lands.
|
||||
@@ -10,17 +10,20 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
|
||||
|
||||
## What the fixture is
|
||||
|
||||
- **Binary**: `ab_server` — a C program in libplctag's
|
||||
`src/tools/ab_server/` ([libplctag/libplctag](https://github.com/libplctag/libplctag),
|
||||
MIT).
|
||||
- **Binary**: `ab_server` — a C program from the upstream
|
||||
[libplctag/libplctag](https://github.com/libplctag/libplctag) repository
|
||||
(MIT license). It is **not** part of this repo's source tree; `Docker/Dockerfile`
|
||||
clones libplctag at a pinned tag and builds the `ab_server` CMake target in a
|
||||
multi-stage build.
|
||||
- **Launcher**: Docker (only supported path). `Docker/Dockerfile`
|
||||
multi-stage-builds `ab_server` from source against a pinned libplctag
|
||||
multi-stage-builds `ab_server` from source by cloning libplctag at a pinned
|
||||
tag + copies the binary into a slim runtime image.
|
||||
`Docker/docker-compose.yml` has per-family services (`controllogix`
|
||||
/ `compactlogix` / `micro800` / `guardlogix`); all bind `:44818`.
|
||||
- **Lifecycle**: `AbServerFixture` TCP-probes `127.0.0.1:44818` at
|
||||
collection init + records a skip reason when unreachable. Tests skip
|
||||
via `[AbServerFact]` / `[AbServerTheory]` which check the same probe.
|
||||
- **Lifecycle**: `AbServerFixture` TCP-probes `10.100.0.35:44818` (the shared
|
||||
Docker host) at collection init + records a skip reason when unreachable.
|
||||
Tests skip via `[AbServerFact]` / `[AbServerTheory]` which check the same
|
||||
probe.
|
||||
- **Profiles**: `KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`
|
||||
in `AbServerProfile.cs` — thin Family + ComposeProfile + Notes records;
|
||||
the compose file is the canonical source of truth for which tags get
|
||||
@@ -71,12 +74,15 @@ Unit coverage: `AbCipAlarmProjectionTests` — fakes feed `InFaulted` /
|
||||
|
||||
### 3. Micro800 unconnected-only path
|
||||
|
||||
Micro800 profile `Notes`: *"ab_server has no --plc micro800 — falls back to
|
||||
controllogix emulation."*
|
||||
Micro800 profile `Notes`: *"--plc=Micro800 mode (unconnected-only, empty path).
|
||||
Driver-side enforcement verified in the unit suite."*
|
||||
|
||||
The empty routing path + unconnected-session requirement (PR 11) is unit-tested
|
||||
but never challenged at the CIP wire level. Real Micro800 (2080-series) on a
|
||||
lab rig would be the authoritative benchmark.
|
||||
The compose service boots `ab_server --plc=Micro800` with an empty routing path.
|
||||
The unconnected-session requirement (PR 11) is validated at the driver unit-test
|
||||
level via `FakeAbCipTagRuntime`; the wire-level contract (what happens when
|
||||
a connected-send arrives at a real Micro800 backplane) is not exercised by the
|
||||
simulator. Real Micro800 (2080-series) on a lab rig would be the authoritative
|
||||
benchmark.
|
||||
|
||||
### 4. GuardLogix safety subsystem
|
||||
|
||||
@@ -177,7 +183,7 @@ project is authored.
|
||||
| "Is my atomic read path wired correctly?" | yes | yes | yes | yes |
|
||||
| "Does whole-UDT grouping work?" | no | yes | **yes** | yes |
|
||||
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
|
||||
| "Is Micro800 unconnected-only enforced wire-side?" | no (emulated as CLX) | partial | yes | yes (required) |
|
||||
| "Is Micro800 unconnected-only enforced wire-side?" | partial (--plc=Micro800 boots, but wire rejection untested) | partial | yes | yes (required) |
|
||||
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
|
||||
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
|
||||
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Coverage map + gap inventory for the FANUC FOCAS2 CNC driver.
|
||||
|
||||
**Status:** as of 2026-04-24, OtOpcUa speaks FOCAS2 directly over TCP
|
||||
via the pure-managed [`Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
|
||||
OtOpcUa speaks FOCAS2 directly over TCP via the pure-managed
|
||||
[`Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
|
||||
client. Integration tests run the managed driver end-to-end against the
|
||||
vendored `focas-mock` Python server (at
|
||||
[`tests/.../Docker/focas-mock/`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md))
|
||||
@@ -51,8 +51,9 @@ message naming the CNC series + documented limit.
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` drives the
|
||||
managed `FocasDriver` end-to-end. A single gate:
|
||||
|
||||
**Docker compose up** — tests skip when the TCP probe to
|
||||
`localhost:8193` fails with a pointer to the compose command.
|
||||
**Docker compose up** — tests skip when the TCP probe fails, with a
|
||||
pointer to the compose command. The endpoint defaults to `localhost:8193`
|
||||
and is overridable via `OTOPCUA_FOCAS_SIM_ENDPOINT`.
|
||||
|
||||
When the mock is up, `WireFocasClient` dials it over TCP exactly like a
|
||||
real CNC, and the mock's native FOCAS Ethernet responder replies with
|
||||
@@ -137,10 +138,10 @@ Or use `scripts/integration/run-focas.ps1` which wraps compose up / test
|
||||
— per-series compose profiles
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs`
|
||||
— collection fixture + mock admin API client
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/FixedTreePopulatesTests.cs`
|
||||
— fixed-tree end-to-end tests
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs`
|
||||
— pure-wire-backend end-to-end tests
|
||||
— fixed-tree end-to-end tests (identity / axes / spindle / program / timers)
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs`
|
||||
— broader wire-backend coverage: PARAM / MACRO / PMC reads, `DiscoverAsync`, `SubscribeAsync`, `IAlarmSource` raise + clear, `IHostConnectivityProbe`
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs` —
|
||||
in-process unit fake
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs` — the
|
||||
|
||||
@@ -4,7 +4,7 @@ The Galaxy driver bridges OtOpcUa to AVEVA System Platform (Wonderware) Galaxies
|
||||
|
||||
For the driver spec (capability surface, config shape, addressing), see [docs/v2/driver-specs.md §1](../v2/driver-specs.md). For the gateway setup recipe, see [docs/v2/Galaxy.ParityRig.md](../v2/Galaxy.ParityRig.md). For tracing, metrics, and soak profile, see [docs/v2/Galaxy.Performance.md](../v2/Galaxy.Performance.md).
|
||||
|
||||
> **Note**: the related drivers `Galaxy-Repository.md` and `Galaxy-Test-Fixture.md` describe the previous v1 / out-of-process topology and are being moved to `docs/v1/` by a parallel cleanup track. Use `Galaxy.ParityRig.md` and the `mxaccessgw` repo for current testing.
|
||||
> **Note**: the related docs [`Galaxy-Repository.md`](../v1/drivers/Galaxy-Repository.md) and [`Galaxy-Test-Fixture.md`](../v1/drivers/Galaxy-Test-Fixture.md) describe the previous v1 / out-of-process topology and now live under `docs/v1/drivers/`. For current testing use [`Galaxy.ParityRig.md`](../v2/Galaxy.ParityRig.md) and the `mxaccessgw` repo.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -65,7 +65,7 @@ Project root files:
|
||||
|
||||
## Capability Surface
|
||||
|
||||
`GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IDisposable`.
|
||||
`GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IDisposable, IAsyncDisposable`.
|
||||
|
||||
| Capability | Implementation entry point |
|
||||
|------------|---------------------------|
|
||||
@@ -75,6 +75,7 @@ Project root files:
|
||||
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
|
||||
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) |
|
||||
| `IHostConnectivityProbe` | `Health/HostStatusAggregator.cs` |
|
||||
| `IAlarmSource` | `Runtime/GatewayGalaxyAlarmFeed.cs` (transitions) + `Runtime/GatewayGalaxyAlarmAcknowledger.cs` (acks) |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Wonderware Historian Backend
|
||||
|
||||
The Wonderware Historian backend is **not a tag driver** — it has no address
|
||||
space, no `IDriver` lifecycle, and exposes no PLC. It is a **server-side
|
||||
historian sink**: an optional sidecar that gives OtOpcUa read access to AVEVA
|
||||
System Platform (Wonderware) Historian history and a write-back path for alarm
|
||||
events. It runs only when `Historian:Wonderware:Enabled=true`.
|
||||
|
||||
For the sidecar's place in a deployment, see
|
||||
[ServiceHosting.md](../ServiceHosting.md). For the alarm-history store-and-forward
|
||||
flow that drains into it, see [AlarmHistorian.md](../AlarmHistorian.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
+-------------------------------------------+
|
||||
| OtOpcUa Host (.NET 10 AnyCPU) |
|
||||
| Server.History.IHistoryRouter --read--+--+
|
||||
| Core.AlarmHistorian.SqliteStore | |
|
||||
| AndForwardSink --write----+--+
|
||||
| WonderwareHistorianClient (.NET 10) | |
|
||||
+-------------------------------------------+ |
|
||||
| named pipe
|
||||
MessagePack frames | (shared secret + allowed-SID)
|
||||
v
|
||||
+-------------------------------------------+
|
||||
| OtOpcUaWonderwareHistorian (sidecar) |
|
||||
| net48 / x64 |
|
||||
| PipeServer + HistorianFrameHandler |
|
||||
| HistorianDataSource (reads) |
|
||||
| SdkAlarmHistorianWriteBackend (writes) |
|
||||
| aahClientManaged / HistorianAccess |
|
||||
+-------------------------------------------+
|
||||
```
|
||||
|
||||
The split exists because the AVEVA Historian SDK (`aahClientManaged` +
|
||||
native `aahClient.dll`) is .NET Framework 4.8 / x64 — so it lives out-of-process
|
||||
in the sidecar, and everything in the OtOpcUa host stays .NET 10 AnyCPU. The
|
||||
host never references the SDK; it speaks the pipe contract only.
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/` | net48 / x64 | The **sidecar** (`OutputType=Exe`). Hosts the named-pipe server, the historian reader, and the alarm-write backend bound to the AVEVA SDK |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/` | net10.0 | `WonderwareHistorianClient` — the in-host pipe client consumed by the history router and the alarm sink |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/` | net10.0 | `WonderwareHistorianClientOptions` (pipe name, shared secret, timeouts) |
|
||||
|
||||
> The csproj targets **net48 / x64** (`PlatformTarget=x64`) — the AVEVA Historian
|
||||
> 2020 SDK ships an x64 `aahClientManaged` build; the earlier x86 default was an
|
||||
> inherited v1 artifact, not a constraint of the Historian SDK.
|
||||
|
||||
## What it does
|
||||
|
||||
The sidecar exposes two surfaces, both over the same named pipe:
|
||||
|
||||
### Read path — `IHistorianDataSource`
|
||||
|
||||
`HistorianDataSource` (in the sidecar) reads history through the
|
||||
`aahClientManaged` SDK; `WonderwareHistorianClient` (in the host) implements
|
||||
`IHistorianDataSource` and maps returned samples back to OPC UA `DataValue`s for
|
||||
`Server.History.IHistoryRouter`. The read surface is:
|
||||
|
||||
| Call | Maps to |
|
||||
|------|---------|
|
||||
| `ReadRawAsync` | Raw historical samples for a tag over a time range |
|
||||
| `ReadProcessedAsync` / `ReadAggregateAsync` | Aggregated samples at an interval |
|
||||
| `ReadAtTimeAsync` | Samples at specific timestamps |
|
||||
| `ReadEventsAsync` | Historical events for a source |
|
||||
| `GetHealthSnapshot` | Connection health for the host-side health surface |
|
||||
|
||||
### Write path — alarm-historian write-back
|
||||
|
||||
`WonderwareHistorianClient` also implements `IAlarmHistorianWriter`. Alarm events
|
||||
are drained into the sidecar from `Core.AlarmHistorian.SqliteStoreAndForwardSink`
|
||||
and persisted by `SdkAlarmHistorianWriteBackend` via
|
||||
`HistorianAccess.AddStreamedValue(HistorianEvent, out HistorianAccessError)`. The
|
||||
production writer is wrapped by `AahClientManagedAlarmEventWriter`, which handles
|
||||
batch orchestration and per-event `HistorianAccessError` outcome classification
|
||||
(connection-class errors are retryable; malformed-argument errors are not).
|
||||
|
||||
The alarm write path can be disabled independently of reads by setting
|
||||
`OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false` — the sidecar then rejects
|
||||
`WriteAlarmEvents` frames while still serving history reads.
|
||||
|
||||
## Hosting and IPC
|
||||
|
||||
- **Process**: `OtOpcUaWonderwareHistorian`, installed/managed by
|
||||
`scripts/install/` (`Install-Services.ps1 -InstallWonderwareHistorian`).
|
||||
- **Spawn config**: the supervisor passes the pipe name, the allowed server
|
||||
principal SID, and a per-process shared secret via environment
|
||||
(`OTOPCUA_HISTORIAN_PIPE`, `OTOPCUA_ALLOWED_SID`, `OTOPCUA_HISTORIAN_SECRET`);
|
||||
Historian connection settings come from `OTOPCUA_HISTORIAN_SERVER` /
|
||||
`_PORT` / `_INTEGRATED` / `_USER` / `_PASS` etc. (see
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs`).
|
||||
- **Pipe-only mode**: with `OTOPCUA_HISTORIAN_ENABLED!=true` the sidecar boots
|
||||
without loading the SDK at all — used for smoke and IPC tests.
|
||||
- **Wire**: MessagePack-framed request/reply; the named-pipe ACL restricts the
|
||||
pipe to the allowed SID and the client proves the shared secret in a Hello
|
||||
frame. The client owns a single channel with one in-flight call at a time and
|
||||
retries a transport failure once before propagating — broader backoff is the
|
||||
caller's responsibility.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Sidecar unit tests** —
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/` cover the
|
||||
reader, the alarm-write backend outcome classification, and the pipe-frame
|
||||
handler with a faked SDK seam.
|
||||
- **Client unit tests** —
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/`
|
||||
cover the pipe client + framing against an in-process duplex pipe pair.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [ServiceHosting.md](../ServiceHosting.md) — where the sidecar fits in a
|
||||
deployment and how it's installed
|
||||
- [AlarmHistorian.md](../AlarmHistorian.md) — the alarm store-and-forward flow
|
||||
that feeds the write-back path
|
||||
@@ -3,11 +3,11 @@
|
||||
Coverage map + gap inventory for the Modbus TCP driver's integration-test
|
||||
harness backed by `pymodbus` simulator profiles per PLC family.
|
||||
|
||||
**TL;DR:** Modbus is the best-covered driver — a real `pymodbus` server on
|
||||
localhost with per-family seed-register profiles, plus a skip-gate when the
|
||||
simulator port isn't reachable. Covers DL205 / Mitsubishi MELSEC / Siemens
|
||||
S7-1500 family quirks end-to-end. Gaps are mostly error-path + alarm/history
|
||||
shaped (neither is a Modbus-side concept).
|
||||
**TL;DR:** Modbus is the best-covered driver — a real `pymodbus` server on the
|
||||
shared Docker host (`10.100.0.35:5020`) with per-family seed-register profiles,
|
||||
plus a skip-gate when the simulator port isn't reachable. Covers DL205 /
|
||||
Mitsubishi MELSEC / Siemens S7-1500 family quirks end-to-end. Gaps are mostly
|
||||
error-path + alarm/history shaped (neither is a Modbus-side concept).
|
||||
|
||||
## What the fixture is
|
||||
|
||||
@@ -16,8 +16,9 @@ shaped (neither is a Modbus-side concept).
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`.
|
||||
Docker is the only supported launch path.
|
||||
- **Lifecycle**: `ModbusSimulatorFixture` (collection-scoped) TCP-probes
|
||||
`localhost:5020` on first use. `MODBUS_SIM_ENDPOINT` env var overrides the
|
||||
endpoint so the same suite can target a real PLC.
|
||||
`10.100.0.35:5020` (the shared Docker host) on first use.
|
||||
`MODBUS_SIM_ENDPOINT` env var overrides the endpoint so the same suite can
|
||||
target a real PLC or a locally-running container.
|
||||
- **Profiles**: `DL205Profile`, `MitsubishiProfile`, `S7_1500Profile` —
|
||||
each composes device-specific register-format + quirk-seed JSON for pymodbus.
|
||||
Profile JSONs live under `Docker/profiles/` and are baked into the image.
|
||||
@@ -102,8 +103,9 @@ Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
|
||||
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
|
||||
1. Add `MODBUS_SIM_ENDPOINT` cross-reference to
|
||||
`docs/v2/test-data-sources.md` (already documented in this page + CLAUDE.md;
|
||||
the v2 page could link here for the complete env-var table).
|
||||
2. ~~Extend `pymodbus` profiles to inject exception responses~~ — **shipped**
|
||||
via the `exception_injection` compose profile + standalone
|
||||
`exception_injector.py` server. Rules in
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Modbus Driver
|
||||
|
||||
In-process native-protocol driver that exposes Modbus-TCP devices as OPC UA
|
||||
variable nodes. It runs inside the OtOpcUa server's .NET 10 AnyCPU process and
|
||||
speaks Modbus-TCP directly over a socket — no gateway, no sidecar, no bitness
|
||||
constraint. Modbus has no discovery protocol and no native push model, so the
|
||||
address space is built entirely from pre-declared tags and subscriptions are a
|
||||
polling overlay on top of `IReadable`.
|
||||
|
||||
For the driver spec (capability surface, config shape, byte-order matrix), see
|
||||
[docs/v2/driver-specs.md §2](../v2/driver-specs.md). For the manual test client,
|
||||
see [Driver.Modbus.Cli.md](../Driver.Modbus.Cli.md). For the integration fixture
|
||||
coverage map, see [Modbus-Test-Fixture.md](Modbus-Test-Fixture.md).
|
||||
|
||||
## Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/` | The driver — `ModbusDriver` plus the `ModbusTcpTransport` socket layer, the connectivity probe, and the auto-prohibition planner. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/` | Shared address grammar — `ModbusAddressParser` and the `ModbusRegion` / `ModbusDataType` / `ModbusByteOrder` / `ModbusFamily` enums. Lives in its own assembly so the Admin UI and the parser can speak about addresses without a transport dependency. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/` | `ModbusDriverOptions` + `ModbusTagDefinition` config records bound from the driver's `DriverConfig` JSON. |
|
||||
|
||||
## Capability Surface
|
||||
|
||||
`ModbusDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable`
|
||||
(`Driver.Modbus/ModbusDriver.cs`). There is **no `IAlarmSource`** and no
|
||||
`IHistoryProvider` — the Modbus protocol expresses neither, so those capabilities
|
||||
are out of scope by design.
|
||||
|
||||
| Capability | Implementation entry point | Notes |
|
||||
|------------|---------------------------|-------|
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Emits one `Modbus/{tag}` variable per pre-declared tag; Modbus has no browse protocol, so the driver returns exactly the configured `Tags`. |
|
||||
| `IReadable` | `ReadAsync` → `ReadOneAsync` / `ReadCoalescedAsync` | FC01/FC02 for coils, FC03/FC04 for registers; auto-chunks reads past the per-device cap. |
|
||||
| `IWritable` | `WriteAsync` → `WriteOneAsync` | FC05/FC15 for coils, FC06/FC16 for registers; `BitInRegister` writes do a per-register read-modify-write under a lock. `DiscreteInputs` / `InputRegisters` are read-only and return `BadNotWritable`. |
|
||||
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | No native push — subscriptions become per-tag polling groups with an optional per-tag `Deadband` filter. |
|
||||
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | Periodic cheap FC03 at `Probe.ProbeAddress`; `HostName` is the `Host:Port` string surfaced to the Admin UI. |
|
||||
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to a per-slave breaker key (`Host:Port/unit{UnitId}`) so a dead RTU slave behind a multi-unit gateway opens its own breaker. |
|
||||
|
||||
## Addressing Model
|
||||
|
||||
Every exposed register is a pre-declared `ModbusTagDefinition` (Region, Address,
|
||||
DataType, ByteOrder, …). Tag spreadsheets are typically authored as address
|
||||
strings parsed by `ModbusAddressParser` at config-bind time; the grammar is
|
||||
`<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]`:
|
||||
|
||||
| Form | Example | Meaning |
|
||||
|------|---------|---------|
|
||||
| Modicon digits | `40001` / `400001` | Holding register 0 (5- or 6-digit form), default Int16 |
|
||||
| Mnemonic prefix | `HR1` / `IR1` / `C100` / `DI5` | Region prefix + 1-based register number |
|
||||
| Bit suffix | `40001.5` | Bit 5 of holding register 0 (`BitInRegister`) |
|
||||
| Explicit type | `40001:F` / `40001:STR20` | Float32 / 20-char ASCII string |
|
||||
| Word order | `40001:F:CDAB` | Float32 with word-swap byte order |
|
||||
| Array | `40001:F:5` | Float32[5] (consumes HR[0..9]) |
|
||||
|
||||
The four regions (`Coils`, `DiscreteInputs`, `InputRegisters`,
|
||||
`HoldingRegisters`) map directly to function-code selection. The type codes are
|
||||
aligned with Wonderware DASMBTCP and the Ignition Modbus driver so pasted tag
|
||||
sheets translate without manual rewriting.
|
||||
|
||||
**Byte/word order** is the most common production misconfiguration. The four
|
||||
`ModbusByteOrder` mnemonics — `ABCD` (BigEndian, spec default), `CDAB`
|
||||
(WordSwap), `BADC` (ByteSwap), `DCBA` (FullReverse) — describe how bytes A/B/C/D
|
||||
appear across consecutive registers when decoding a multi-register value.
|
||||
|
||||
## Device Profiles
|
||||
|
||||
`ModbusDriverOptions.Family` selects a parser family-native branch
|
||||
(`ModbusFamily`):
|
||||
|
||||
- **`Generic`** (default) — only Modicon (`4xxxx`) and mnemonic (`HR1`, `C100`) forms are accepted.
|
||||
- **`DL205`** — AutomationDirect DirectLOGIC. V-memory (octal) → HoldingRegisters, `Y`/`C` → Coils, `X`/`SP` → DiscreteInputs. Strings can be packed low-byte-first via `ModbusTagDefinition.StringByteOrder` (the grammar can't express this — see `ModbusStringByteOrder`).
|
||||
- **`MELSEC`** — Mitsubishi. D-registers → HoldingRegisters, `X` → DiscreteInputs, `Y`/`M` → Coils; the `MelsecSubFamily` selector switches Q/L/iQR (hex) vs FX (octal) X/Y interpretation.
|
||||
|
||||
Per-family register caps are honoured through `MaxRegistersPerRead` /
|
||||
`MaxRegistersPerWrite` / `MaxCoilsPerRead` (e.g. DL205/DL260 cap reads at 128,
|
||||
Mitsubishi Q at 64); the driver auto-chunks larger reads into consecutive
|
||||
requests.
|
||||
|
||||
## Coalesced Reads + Auto-Prohibition
|
||||
|
||||
When `MaxReadGap > 0` the read planner (`ReadCoalescedAsync`) groups tags in the
|
||||
same `(UnitId, Region)`, sorts by address, and merges near-adjacent register
|
||||
spans (gap ≤ `MaxReadGap`, total span ≤ the read cap) into a single FC03/FC04
|
||||
PDU, then slices the response back into per-tag values. If a coalesced read hits
|
||||
a Modbus exception (illegal/protected register), the offending range is recorded
|
||||
as **auto-prohibited** so the planner stops re-coalescing across it; the
|
||||
surviving members fall back to per-tag reads in the same scan. Setting
|
||||
`AutoProhibitReprobeInterval` starts a background loop that periodically retries
|
||||
prohibited ranges and uses bisection to narrow a multi-register prohibition down
|
||||
to the actual offending register(s). Per-tag escape hatch:
|
||||
`ModbusTagDefinition.CoalesceProhibited`.
|
||||
|
||||
## Configuration
|
||||
|
||||
`ModbusDriverOptions` (`Driver.Modbus.Contracts/ModbusDriverOptions.cs`) binds
|
||||
from the driver's `DriverConfig` JSON. Key fields:
|
||||
|
||||
- **Endpoint** — `Host`, `Port` (default 502), `UnitId`, `Timeout`. Per-tag `UnitId` overrides drive multi-slave gateway topology.
|
||||
- **`Tags`** — the pre-declared `ModbusTagDefinition` list; this *is* the address space.
|
||||
- **`Probe`** — connectivity-probe interval / timeout / probe register (default register 0).
|
||||
- **Read/write caps** — `MaxRegistersPerRead` (125), `MaxRegistersPerWrite` (123), `MaxCoilsPerRead` (2000), plus `MaxReadGap` and `AutoProhibitReprobeInterval` for coalescing.
|
||||
- **Function-code overrides** — `UseFC15ForSingleCoilWrites`, `UseFC16ForSingleRegisterWrites` for PLCs that only accept multi-write codes.
|
||||
- **Resilience** — `AutoReconnect`, `KeepAlive`, `IdleDisconnectTimeout`, `Reconnect` backoff, and `WriteOnChangeOnly` redundant-write suppression.
|
||||
|
||||
Full per-field descriptions live in `ModbusDriverOptions.cs`. The JSON skeleton
|
||||
is reproduced in [docs/v2/driver-specs.md §2](../v2/driver-specs.md).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/` (driver behaviour via a fake transport) and `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/` (the address grammar).
|
||||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` run against the Docker Modbus simulator fixture. See [Modbus-Test-Fixture.md](Modbus-Test-Fixture.md) for the coverage map and the `MODBUS_SIM_ENDPOINT` wiring.
|
||||
- **Manual client** — [Driver.Modbus.Cli.md](../Driver.Modbus.Cli.md).
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- **Wrong-endian readings are silently plausible.** A byte-order misconfiguration produces a wrong number, not a Bad quality code — surface byte-order mismatches as data-validation alerts, not status codes (see [docs/v2/driver-specs.md §2](../v2/driver-specs.md)).
|
||||
- **`WriteOnChangeOnly` + write-only tags** — the suppression cache is only invalidated by a read that returns a divergent value. A tag that is never subscribed/polled never refreshes its cache entry, so a re-asserted value can be suppressed indefinitely. Subscribe every tag that needs deterministic re-writes, or leave the option off.
|
||||
- **Auto-prohibited ranges** are visible via `GetAutoProhibitedRanges` and logged on first occurrence / on clear — use them to find protected register holes in a device's map.
|
||||
@@ -20,7 +20,8 @@ image (follow-up).
|
||||
**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://localhost:50000`. `OpcPlcFixture` probes the port at
|
||||
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
|
||||
@@ -81,12 +82,15 @@ Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. Real stack exchange
|
||||
### 1. Full real-stack exchange (unit tests only)
|
||||
|
||||
No UA Secure Channel is ever opened. Every test mocks `Session.ReadAsync`,
|
||||
`Session.CreateSubscription`, `Session.AddItem`, etc. — the SDK itself is
|
||||
trusted. Certificate validation, signing, nonce handling, chunk assembly,
|
||||
keep-alive cadence — all SDK-internal and untested here.
|
||||
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
|
||||
|
||||
@@ -124,21 +128,24 @@ ConditionType events (non-base `BaseEventType`) is not verified.
|
||||
|
||||
## When to trust OpcUaClient tests, when to reach for a server
|
||||
|
||||
| Question | Unit tests | 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/write round-trip work?" | no | yes (required) |
|
||||
| "Does event-filter-based alarm subscription return ConditionType events?" | no | yes (required) |
|
||||
| "Does history read from AVEVA Historian return correct aggregates?" | no | yes (required) |
|
||||
| "Does the SDK's publish queue lose notifications under load?" | no | yes (stress) |
|
||||
| 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 integration test project
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs`
|
||||
already stands up a real OPC UA server on a non-default port with a seeded
|
||||
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:
|
||||
|
||||
@@ -163,8 +170,17 @@ Beyond that:
|
||||
|
||||
- `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
|
||||
- `tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` —
|
||||
the server-side integration harness a future loopback client test could
|
||||
piggyback on
|
||||
session-factory seam tests mock through; implements `IAlarmSource` +
|
||||
`IHistoryProvider` (unique among drivers); 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)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# OPC UA Client (Gateway) Driver
|
||||
|
||||
Getting-started guide for the OPC UA Client driver. This is the short path — for
|
||||
the full per-field spec read [`docs/v2/driver-specs.md §8`](../v2/driver-specs.md),
|
||||
and for the test-harness map read [OpcUaClient-Test-Fixture.md](OpcUaClient-Test-Fixture.md).
|
||||
|
||||
## What it talks to
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
out-of-process isolation.
|
||||
|
||||
> There is **no standalone driver CLI** for the OPC UA Client driver. To exercise
|
||||
> a remote OPC UA endpoint by hand, point the general-purpose
|
||||
> [Client CLI](../Client.CLI.md) at it directly.
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/` | net10.0 | In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/` | net10.0 | Config records + enums bound from `DriverConfig` JSON |
|
||||
|
||||
## Minimum deployment
|
||||
|
||||
```jsonc
|
||||
"Drivers": {
|
||||
"upstream-1": {
|
||||
"Type": "OpcUaClient",
|
||||
"Config": {
|
||||
"EndpointUrl": "opc.tcp://plc.internal:4840",
|
||||
"SecurityPolicy": "None",
|
||||
"SecurityMode": "None",
|
||||
"AuthType": "Anonymous",
|
||||
"TargetNamespaceKind": "Equipment"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`EndpointUrls` (a list) takes precedence over the single-URL `EndpointUrl` and
|
||||
provides ordered **failover** — the driver tries each candidate in turn at init
|
||||
and on session drop, and the first to connect wins (e.g. a hot-standby pair on
|
||||
4840 / 4841). See
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs`
|
||||
for every field (security policy/mode, auth type, session timeout, keep-alive,
|
||||
reconnect period, browse root, node/depth caps).
|
||||
|
||||
### Session lifetime
|
||||
|
||||
A single `Session` per driver instance; subscriptions multiplex onto it. The
|
||||
SDK reconnect handler takes the session down and brings it back on remote-server
|
||||
restart, re-sending subscriptions on reconnect so monitored-item handles don't
|
||||
dangle. Stored NodeIds embed the server-stable namespace **URI** (not the
|
||||
session-relative `ns=N` index) so a remote namespace-table reorder across a
|
||||
restart doesn't silently re-point references at the wrong namespace.
|
||||
|
||||
### Namespace assignment
|
||||
|
||||
This is the only driver that gateways into **either** namespace kind, decided
|
||||
per instance via `TargetNamespaceKind`:
|
||||
|
||||
- `Equipment` — the remote server exposes raw equipment data; remote browse
|
||||
paths are remapped to UNS via a required `UnsMappingTable`.
|
||||
- `SystemPlatform` — the remote server exposes processed/derived data; the
|
||||
remote hierarchy is preserved with no UNS conversion (and the mapping table
|
||||
must be empty).
|
||||
|
||||
The choice is enforced at startup so a misconfiguration fails draft validation
|
||||
rather than surfacing as a runtime surprise.
|
||||
|
||||
## Capability surface
|
||||
|
||||
`OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs:31`).
|
||||
|
||||
| Capability | Path | Notes |
|
||||
|------------|------|-------|
|
||||
| `ITagDiscovery` | `DiscoverAsync` (recursive browse) | Mirrors the upstream tree from `BrowseRoot` (default `ObjectsFolder` i=85), bounded by `MaxDiscoveredNodes` / `MaxBrowseDepth` |
|
||||
| `IReadable` | `ReadAsync` → `Session.ReadAsync` | Upstream `StatusCode`s pass through verbatim (cascading-quality rule) |
|
||||
| `IWritable` | `WriteAsync` → `Session.WriteAsync` | Passthrough write |
|
||||
| `ISubscribable` | native OPC UA subscriptions / monitored items | The remote server pushes data changes |
|
||||
| `IHostConnectivityProbe` | session keep-alive | Host key is the endpoint URL actually connected to after the failover sweep |
|
||||
| `IAlarmSource` | `SubscribeAlarmsAsync` (EventFilter) + `AcknowledgeAsync` | Subscribes to upstream alarm/condition events and forwards acks |
|
||||
| `IHistoryProvider` | `ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` → `Session.HistoryReadAsync` | **Unique to this driver** — passthrough history read against the upstream server |
|
||||
|
||||
> This driver does **not** implement `IRediscoverable` — there is no
|
||||
> push-driven rediscovery signal from a remote OPC UA server in this driver.
|
||||
> `IHistoryProvider` is implemented by no other driver; history reads for every
|
||||
> other source route server-side through `IHistoryRouter`.
|
||||
|
||||
### History passthrough
|
||||
|
||||
`IHistoryProvider` forwards `HistoryRead` to the upstream server's own historian.
|
||||
Raw, processed (Average / Minimum / Maximum / Total / Count aggregates mapped to
|
||||
OPC UA Part 13 standard aggregate NodeIds), and at-time reads are supported; each
|
||||
returned `DataValue` keeps its upstream `StatusCode` and timestamps verbatim.
|
||||
Event-history (`ReadEventsAsync`) is left at the interface default — the
|
||||
interface doesn't yet carry the EventFilter surface needed to forward it.
|
||||
|
||||
### Certificate trust
|
||||
|
||||
`AutoAcceptCertificates` accepts any self-signed / untrusted server certificate.
|
||||
It is **dev-only** — leave it `false` in production so a MITM against the
|
||||
opc.tcp channel fails closed.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/`
|
||||
cover the session lifecycle, namespace remapping, alarm/history passthrough,
|
||||
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
|
||||
[OpcUaClient-Test-Fixture.md](OpcUaClient-Test-Fixture.md) for the coverage map.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [`docs/v2/driver-specs.md §8`](../v2/driver-specs.md) — full per-field spec,
|
||||
namespace-assignment rules, and cascading-quality detail
|
||||
- [OpcUaClient-Test-Fixture.md](OpcUaClient-Test-Fixture.md) — test-harness map
|
||||
- [Client.CLI.md](../Client.CLI.md) — general-purpose OPC UA client CLI for
|
||||
ad-hoc browsing of any endpoint
|
||||
+32
-19
@@ -9,8 +9,9 @@ OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `C
|
||||
- `IHostConnectivityProbe` — per-host reachability events
|
||||
- `IPerCallHostResolver` — multi-host drivers that route each call to a target endpoint at dispatch time
|
||||
- `IAlarmSource` — driver-emitted OPC UA A&C events
|
||||
- `IHistoryProvider` — raw / processed / at-time / events HistoryRead (see [HistoricalDataAccess.md](../HistoricalDataAccess.md))
|
||||
- `IHistoryProvider` — driver-side raw / processed / at-time / events HistoryRead (see [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md))
|
||||
- `IRediscoverable` — driver-initiated address-space rebuild notifications
|
||||
- `IHistorianDataSource` — server-side historian sink registration (the Wonderware Historian backend), distinct from the driver-side `IHistoryProvider` HistoryRead path
|
||||
|
||||
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`), which wraps it in a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. The `OTOPCUA0001` analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
|
||||
|
||||
@@ -20,25 +21,37 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
|
||||
|
||||
| Driver | Project path | Tier | Wire / library | Capabilities | Notable quirk |
|
||||
|--------|--------------|:----:|----------------|--------------|---------------|
|
||||
| [Galaxy](Galaxy.md) | `Driver.Galaxy.{Shared, Host, Proxy}` | C | MXAccess COM + `aahClientManaged` + SqlClient | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe | Out-of-process — Host is its own Windows service (.NET 4.8 x86 for the COM bitness constraint); Proxy talks to Host over a named pipe |
|
||||
| Modbus TCP | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
|
||||
| Siemens S7 | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
|
||||
| AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
||||
| AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
||||
| TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
|
||||
| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | Read-only by design (WriteAsync returns `BadNotWritable`). CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
|
||||
| OPC UA Client | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver. Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
|
||||
| [Galaxy](Galaxy.md) | `Driver.Galaxy` (+ `.Browser`, `.Contracts`) | A | gRPC to the external `mxaccessgw` gateway (the gateway owns MXAccess COM + the Galaxy Repository SQL reader) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IRediscoverable, IHostConnectivityProbe | In-process .NET 10 driver — the COM bitness constraint lives in the gateway's x86 net48 worker, not here. PR 7.2 retired the legacy in-process `Galaxy.{Shared, Host, Proxy}` + named-pipe Windows service. Native MxAccess alarms work end-to-end |
|
||||
| [Modbus TCP](Modbus.md) | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
|
||||
| [Siemens S7](S7.md) | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
|
||||
| [AB CIP](AbCip.md) | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
||||
| [AB Legacy](AbLegacy.md) | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
||||
| [TwinCAT](TwinCAT.md) | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
|
||||
| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | `IWritable` is implemented but read-only by design — `WriteAsync` returns `BadNotWritable` for every point. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
|
||||
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
|
||||
| [Historian.Wonderware](Historian.Wonderware.md) | `Driver.Historian.Wonderware` (+ `.Client`, `.Client.Contracts`) | — | `aahClientManaged` write SDK + AVEVA Historian SQL, over a pipe IPC backend | IHistorianDataSource (server-side historian sink) | Not a tag driver — a historian backend that registers `IHistorianDataSource` (`HistorianDataSource : IHistorianDataSource`) to satisfy HistoryRead and to sink tag/alarm history. No `IDriver`/`ITagDiscovery` surface |
|
||||
|
||||
## Per-driver documentation
|
||||
|
||||
- **Galaxy** has its own docs in this folder because the out-of-process architecture + MXAccess COM rules + Galaxy Repository SQL + Historian + runtime probe manager don't fit a single table row:
|
||||
- [Galaxy.md](Galaxy.md) — COM bridge, STA pump, IPC, runtime probes
|
||||
- [Galaxy-Repository.md](Galaxy-Repository.md) — ZB SQL reader, `LocalPlatform` scope filter, change detection
|
||||
- **Galaxy** has its own docs in this folder because the gRPC-to-gateway architecture + MXAccess rules (owned by the gateway) + Galaxy Repository SQL + Historian + runtime probe manager don't fit a single table row:
|
||||
- [Galaxy.md](Galaxy.md) — gateway gRPC bridge, hierarchy source, runtime probes
|
||||
- [Galaxy-Repository.md](../v1/drivers/Galaxy-Repository.md) — ZB SQL reader, `LocalPlatform` scope filter, change detection (v1 archive)
|
||||
|
||||
- **FOCAS** has a short getting-started doc because the Tier-C two-project deployment + backend-selection env var + alarm projection opt-in all need explaining up front:
|
||||
- **FOCAS** has a short getting-started doc because the backend-selection env var + alarm projection opt-in need explaining up front:
|
||||
- [FOCAS.md](FOCAS.md) — deployment, config, capability surface, alarm projection, troubleshooting
|
||||
|
||||
- **All other drivers** share a single per-driver specification in [docs/v2/driver-specs.md](../v2/driver-specs.md) — addressing, data-type maps, connection settings, and quirks live there. That file is the authoritative per-driver reference; this index points at it rather than duplicating.
|
||||
- **Modbus TCP**, **AB CIP**, **AB Legacy**, **Siemens S7**, **TwinCAT**, and **OPC UA Client** each have a per-driver overview page:
|
||||
- [Modbus.md](Modbus.md) — in-process Modbus-TCP driver: address formats, polled subscription model, DL205 octal mapping
|
||||
- [AbCip.md](AbCip.md) — AB CIP / EtherNet-IP driver (ControlLogix / CompactLogix / Micro800 / GuardLogix): tag discovery, UDT resolution, alarm source
|
||||
- [AbLegacy.md](AbLegacy.md) — AB Legacy PCCC driver (SLC 500 / MicroLogix / PLC-5): file-based addressing, user-authored tag list
|
||||
- [S7.md](S7.md) — Siemens S7 driver (S7-300/400/1200/1500 + S7-200): getting started, config, data-block addressing, serialized single-connection model
|
||||
- [TwinCAT.md](TwinCAT.md) — Beckhoff TwinCAT (ADS) driver: getting started, native-notification subscription, symbol-tree upload
|
||||
- [OpcUaClient.md](OpcUaClient.md) — OPC UA Client (gateway/aggregation) driver: remote-server session, driver-side HistoryRead forwarding, reconnect behaviour
|
||||
|
||||
- **Historian.Wonderware** (server-side historian sink, not a tag driver) has its own overview page:
|
||||
- [Historian.Wonderware.md](Historian.Wonderware.md) — AVEVA Historian backend: sink registration, HistoryRead dispatch, alarm store-and-forward, deployment prerequisites
|
||||
|
||||
- The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in [docs/v2/driver-specs.md](../v2/driver-specs.md). The overview pages above are the short path; that file is the authoritative per-driver reference.
|
||||
|
||||
## Test-fixture coverage maps
|
||||
|
||||
@@ -50,13 +63,13 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those
|
||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
||||
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
|
||||
- [Galaxy](Galaxy-Test-Fixture.md) — richest harness: E2E Host subprocess + ZB SQL live-smoke + MXAccess opt-in
|
||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session`
|
||||
- [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
|
||||
|
||||
## Related cross-driver docs
|
||||
|
||||
- [HistoricalDataAccess.md](../HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The Galaxy driver's Aveva Historian implementation is the first; OPC UA Client forwards to the upstream server; other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
|
||||
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering.
|
||||
- [Subscriptions.md](../Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
|
||||
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the Aveva Historian path is served server-side by the Wonderware `IHistorianDataSource` sink instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
|
||||
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, and TwinCAT have no alarm source.
|
||||
- [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
|
||||
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules.
|
||||
- [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy.
|
||||
|
||||
@@ -6,17 +6,19 @@ Coverage map + gap inventory for the S7 driver.
|
||||
[python-snap7](https://github.com/gijzelaerr/python-snap7)'s `Server` class
|
||||
(task #216). Atomic reads (u16 / i16 / i32 / f32 / bool-with-bit) + DB
|
||||
write-then-read round-trip are exercised end-to-end through S7netplus +
|
||||
real ISO-on-TCP on `localhost:1102`. Unit tests still carry everything
|
||||
else (address parsing, error-branch handling, probe-loop contract). Gaps
|
||||
remaining are variant-quirk-shaped: Optimized-DB symbolic access, PG/OP
|
||||
session types, PUT/GET-disabled enforcement — all need real hardware.
|
||||
real ISO-on-TCP on `10.100.0.35:1102` (the shared Docker host; override via
|
||||
`S7_SIM_ENDPOINT`). Unit tests still carry everything else (address parsing,
|
||||
error-branch handling, probe-loop contract). Gaps remaining are
|
||||
variant-quirk-shaped: Optimized-DB symbolic access, PG/OP session types,
|
||||
PUT/GET-disabled enforcement — all need real hardware.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #216):
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/` stands up a
|
||||
python-snap7 `Server` via `Docker/docker-compose.yml --profile s7_1500`
|
||||
on `localhost:1102` (pinned `python:3.12-slim-bookworm` base +
|
||||
on `10.100.0.35:1102` (the shared Docker host; override via `S7_SIM_ENDPOINT`;
|
||||
pinned `python:3.12-slim-bookworm` base +
|
||||
`python-snap7>=2.0`). Docker is the only supported launch path.
|
||||
`Snap7ServerFixture` probes the port at collection init + skips with a
|
||||
clear message when unreachable (matches the pymodbus pattern).
|
||||
@@ -60,18 +62,20 @@ Wire-level surfaces verified: `IReadable`, `IWritable`.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. Wire-level anything
|
||||
### 1. Wire-level anything (unit tests only)
|
||||
|
||||
No ISO-on-TCP frame is ever sent during the test suite. S7netplus is the only
|
||||
wire-path abstraction and it has no in-process fake mode; the shipping choice
|
||||
was to contract-test via `IS7Client` rather than patch into S7netplus
|
||||
internals.
|
||||
The **unit** suite (`S7DriverReadWriteTests`, etc.) sends no real ISO-on-TCP
|
||||
frames. S7netplus has no in-process fake mode; units contract-test via the
|
||||
`IS7Client` abstraction. The **integration** suite (`S7_1500SmokeTests`, task
|
||||
#216) does send real S7comm over ISO-on-TCP against the python-snap7 container
|
||||
and covers the basic read / write / typed-batch path.
|
||||
|
||||
### 2. Read/write happy path
|
||||
### 2. Error-branch unit tests vs. real round-trips
|
||||
|
||||
Every `S7DriverReadWriteTests` case exercises error branches. A successful
|
||||
read returning real PLC data is not tested end-to-end — the return value is
|
||||
whatever the fake says it is.
|
||||
`S7DriverReadWriteTests` (unit) exercises error paths only; return values come
|
||||
from the fake. The integration suite exercises the successful read / write
|
||||
round-trip, but only against the python-snap7 emulator — not a real Siemens
|
||||
CPU.
|
||||
|
||||
### 3. Mailbox serialization under concurrent reads
|
||||
|
||||
@@ -91,31 +95,40 @@ arrays of structs — not covered.
|
||||
|
||||
## When to trust the S7 tests, when to reach for a rig
|
||||
|
||||
| Question | Unit tests | Real PLC |
|
||||
| --- | --- | --- |
|
||||
| "Does the address parser accept X syntax?" | yes | - |
|
||||
| "Does the driver lifecycle hang / crash?" | yes | yes |
|
||||
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
|
||||
| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
|
||||
| "Does a UDT fan-out produce usable member variables?" | no | yes (required) |
|
||||
| Question | Unit tests | Integration (python-snap7) | Real PLC |
|
||||
| --- | --- | --- | --- |
|
||||
| "Does the address parser accept X syntax?" | yes | - | - |
|
||||
| "Does the driver lifecycle hang / crash?" | yes | yes | yes |
|
||||
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (basic scalars) | yes (required for full type matrix) |
|
||||
| "Does mailbox serialization actually prevent PG timeouts?" | no | no | yes (required) |
|
||||
| "Does a UDT fan-out produce usable member variables?" | no | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **Snap7 server** — [Snap7](https://snap7.sourceforge.net/) ships a
|
||||
C-library-based S7 server that could run in-CI on Linux. A pinned build +
|
||||
a fixture shape similar to `ab_server` would give S7 parity with Modbus /
|
||||
AB CIP coverage.
|
||||
2. **Plcsim Advanced** — Siemens' paid emulator. Licensed per-seat; fits a
|
||||
lab rig but not CI.
|
||||
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
|
||||
network port, wired via self-hosted runner.
|
||||
The python-snap7 fixture (task #216) covers scalar read / write / typed-batch.
|
||||
Remaining gaps need one of:
|
||||
|
||||
Without any of these, S7 driver correctness against real hardware is trusted
|
||||
1. **Plcsim Advanced** — Siemens' paid emulator; gives Optimized-DB symbolic
|
||||
access + PG/OP/S7-Basic session differentiation without real hardware.
|
||||
Licensed per-seat; fits a lab rig but not CI.
|
||||
2. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
|
||||
network port, wired via self-hosted runner. Only path for mailbox
|
||||
serialization / PUT-GET enforcement verification.
|
||||
|
||||
Without either, S7 driver correctness for variant-quirk edge cases is trusted
|
||||
from field deployments, not from the test suite.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` — unit tests only, no harness
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs`
|
||||
— collection fixture; parses `S7_SIM_ENDPOINT` (default `10.100.0.35:1102`),
|
||||
TCP-probes at collection init, records `SkipReason` when unreachable
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SmokeTests.cs`
|
||||
— wire-level test suite (3 `[Fact]` methods: u16 read, typed batch, write-then-read)
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml`
|
||||
— one service per profile (`s7_1500`); binds `1102:1102` on the Docker host
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json`
|
||||
— DB1 + MB seed layout with typed seeds at known offsets
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` — ctor takes
|
||||
`IS7ClientFactory` which tests fake; docstring lines 8-20 note the deferred
|
||||
integration fixture
|
||||
`IS7ClientFactory` which tests fake
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Siemens S7 Driver
|
||||
|
||||
Getting-started guide for the Siemens S7 driver. This is the short path — for
|
||||
the full per-field spec read [`docs/v2/driver-specs.md §5`](../v2/driver-specs.md),
|
||||
for hands-on CLI testing read [Driver.S7.Cli.md](../Driver.S7.Cli.md), and for
|
||||
the test-harness map read [S7-Test-Fixture.md](S7-Test-Fixture.md).
|
||||
|
||||
## What it talks to
|
||||
|
||||
Siemens S7 PLCs — S7-300, S7-400, S7-1200, S7-1500, plus S7-200 / S7-200 Smart
|
||||
/ LOGO! 0BA8 — over the native **S7comm** protocol on **ISO-on-TCP, TCP port
|
||||
102**. The wire is spoken by the pure-managed [S7netplus](https://github.com/S7NetPlus/s7netplus)
|
||||
(`S7.Net`) library: no native DLL, no P/Invoke, no out-of-process isolation. The
|
||||
driver runs in-process in the OtOpcUa server's .NET 10 AnyCPU host on every OS
|
||||
the server runs on.
|
||||
|
||||
This is the **leanest** OtOpcUa driver — read/write/subscribe/discover plus a
|
||||
connectivity probe, and nothing else. It implements no alarm source and no
|
||||
per-call host resolver (a single S7 instance targets a single CPU).
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/` | net10.0 | In-process driver — hosts the `S7.Net.Plc` connection and the address parser |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/` | net10.0 | Dependency-free config records + enums (`S7DriverOptions`, `S7CpuType`, `S7DataType`) bound from `DriverConfig` JSON |
|
||||
|
||||
## Minimum deployment
|
||||
|
||||
Register the driver instance in the central config DB (or `appsettings.json`).
|
||||
No separate service, no DLL deployment:
|
||||
|
||||
```jsonc
|
||||
"Drivers": {
|
||||
"s7-line-1": {
|
||||
"Type": "S7",
|
||||
"Config": {
|
||||
"Host": "10.20.30.40",
|
||||
"CpuType": "S71500",
|
||||
"Rack": 0,
|
||||
"Slot": 0,
|
||||
"Tags": [
|
||||
{ "Name": "Running", "Address": "DB1.DBX0.0", "DataType": "Bool", "Writable": false },
|
||||
{ "Name": "Speed", "Address": "DB1.DBD4", "DataType": "Float32", "Writable": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
S7 exposes a symbol table, but `S7.Net` does not surface it — so the driver
|
||||
operates off a **static, per-site tag list**, not live symbol discovery.
|
||||
|
||||
### Rack / slot / CPU family
|
||||
|
||||
`CpuType` selects the ISO-TSAP slot byte used during the connection handshake;
|
||||
pick the family that matches the PLC exactly. `Rack` is almost always `0`
|
||||
(relevant only for distributed S7-400 racks). `Slot` conventions per family:
|
||||
S7-300 = slot 2, S7-400 = slot 2 or 3, S7-1200 / S7-1500 = slot 0 (onboard PN).
|
||||
A wrong slot causes a connection refusal during the handshake. See
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs` for the
|
||||
per-field defaults.
|
||||
|
||||
## Address forms
|
||||
|
||||
Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs`:
|
||||
|
||||
| Area | Example | Meaning |
|
||||
|------|---------|---------|
|
||||
| Data block | `DB1.DBX0.0` / `DB1.DBW0` / `DB1.DBD4` | DB number + size suffix `X`(bit) / `B`(byte) / `W`(word) / `D`(dword), optional `.bit` for `DBX` |
|
||||
| Merker (M) | `MB0` / `MW0` / `MD4` / `M0.0` | Marker byte; size prefix `B`/`W`/`D`, or bare offset `.bit` for bit access |
|
||||
| Input (I) | `IB0` / `IW0` / `I0.0` | Process-image input |
|
||||
| Output (Q) | `QB0` / `QW0` / `Q0.0` | Process-image output |
|
||||
|
||||
Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
|
||||
at load instead of surfacing as `BadInternalError` on every read. Bit offsets
|
||||
must be 0-7, byte offsets non-negative, DB numbers >= 1.
|
||||
|
||||
> **Timer (`T{n}`) and Counter (`C{n}`)** addresses parse cleanly but the read
|
||||
> path has no decode case for them yet — the driver rejects them at init with an
|
||||
> explicit error rather than letting them surface a misleading type-mismatch.
|
||||
|
||||
## Data types
|
||||
|
||||
`S7DataType` declares the **semantic** type; `S7.Net` returns an unsigned boxed
|
||||
value (bool / byte / ushort / uint) that the driver reinterprets without an
|
||||
extra PLC round-trip. Wired through today: `Bool`, `Byte`, `Int16`, `UInt16`,
|
||||
`Int32`, `UInt32`, `Float32`. `Int64`, `UInt64`, `Float64`, `String`, and
|
||||
`DateTime` are declared in the enum but **rejected at init** — half-implemented
|
||||
types must not create OPC UA nodes that then return `BadNotSupported` on every
|
||||
access.
|
||||
|
||||
## Capability surface
|
||||
|
||||
`S7Driver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs`).
|
||||
|
||||
| Capability | Path | Notes |
|
||||
|------------|------|-------|
|
||||
| `IReadable` | `ReadAsync` → `S7.Net.Plc.ReadAsync` | One request/response per tag, serialized on a per-PLC semaphore |
|
||||
| `IWritable` | `WriteAsync` → `S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` |
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse |
|
||||
| `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) |
|
||||
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
|
||||
|
||||
### Single-connection policy
|
||||
|
||||
One `S7.Net.Plc` instance per PLC, serialized with a `SemaphoreSlim`.
|
||||
Parallelising reads against a single CPU doesn't help — the CPU scans its
|
||||
comms mailbox at most once per cycle and queues concurrent requests wire-side
|
||||
anyway, while wasting the CPU's 8-64 connection-resource budget.
|
||||
|
||||
## PUT/GET communication
|
||||
|
||||
S7-1200 / S7-1500 ship with **PUT/GET access disabled** by default. A driver
|
||||
pointed at a freshly-flashed CPU sees a hard access-denied fault. The driver
|
||||
maps it specifically to `BadNotSupported`, flags the instance `Faulted` (a
|
||||
configuration alert, not a transient fault), and does **not** blind-retry —
|
||||
because the CPU will keep refusing. Fix: enable PUT/GET communication in TIA
|
||||
Portal under *Protection & Security* for the CPU.
|
||||
|
||||
## Error mapping
|
||||
|
||||
| Condition | StatusCode | Health |
|
||||
|-----------|------------|--------|
|
||||
| Tag not in config | `BadNodeIdUnknown` | unchanged |
|
||||
| Read-only tag written | `BadNotWritable` | unchanged |
|
||||
| Unimplemented data type | `BadNotSupported` | unchanged |
|
||||
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
|
||||
| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
|
||||
| Socket / timeout | `BadCommunicationError` | `Degraded` |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` cover the
|
||||
address parser, the reinterpret/box conversions, and the driver lifecycle.
|
||||
- **Integration fixture** — a Docker S7 simulator on the shared docker host; see
|
||||
[S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint.
|
||||
- **CLI** — [Driver.S7.Cli.md](../Driver.S7.Cli.md) documents the standalone
|
||||
read/write/probe CLI for manual checks against a real or simulated CPU.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [`docs/v2/driver-specs.md §5`](../v2/driver-specs.md) — full per-field spec,
|
||||
DriverConfig JSON shape, and operational stability notes
|
||||
- [Driver.S7.Cli.md](../Driver.S7.Cli.md) — standalone S7 driver CLI
|
||||
- [S7-Test-Fixture.md](S7-Test-Fixture.md) — simulator + test-harness map
|
||||
@@ -0,0 +1,129 @@
|
||||
# Beckhoff TwinCAT (ADS) Driver
|
||||
|
||||
Getting-started guide for the Beckhoff TwinCAT driver. This is the short path —
|
||||
for the full per-field spec read [`docs/v2/driver-specs.md §6`](../v2/driver-specs.md),
|
||||
for hands-on CLI testing read [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md),
|
||||
and for the test-harness map read [TwinCAT-Test-Fixture.md](TwinCAT-Test-Fixture.md).
|
||||
|
||||
## What it talks to
|
||||
|
||||
Beckhoff PLC runtimes — **TwinCAT 2 and TwinCAT 3** — over the Beckhoff **ADS**
|
||||
protocol carried by **AMS** routing. The driver runs in-process in the OtOpcUa
|
||||
server's .NET 10 AnyCPU host. It compiles and runs without a local AMS router,
|
||||
but every wire call returns `BadCommunicationError` until a router is reachable
|
||||
(the router translates an AMS Net ID to an IP route).
|
||||
|
||||
Addressing is **symbol-based**: tags are referenced by their TwinCAT symbolic
|
||||
name (e.g. `MAIN.bStart`, `GVL.Counter`, `Motor1.Status.Running`) rather than by
|
||||
raw memory offset. One driver instance fans out to N targets, each identified by
|
||||
an AMS Net ID + port.
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/` | net10.0 | In-process driver — hosts the ADS client, symbol-path parser, and per-device probe loops |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/` | net10.0 | Config records + the `TwinCATDataType` enum bound from `DriverConfig` JSON |
|
||||
|
||||
## Minimum deployment
|
||||
|
||||
```jsonc
|
||||
"Drivers": {
|
||||
"twincat-cell-1": {
|
||||
"Type": "TwinCAT",
|
||||
"Config": {
|
||||
"Devices": [ { "HostAddress": "ads://5.23.91.23.1.1:851", "DeviceName": "Cell1" } ],
|
||||
"Tags": [
|
||||
{ "Name": "Start", "DeviceHostAddress": "ads://5.23.91.23.1.1:851",
|
||||
"SymbolPath": "MAIN.bStart", "DataType": "Bool", "Writable": true },
|
||||
{ "Name": "Count", "DeviceHostAddress": "ads://5.23.91.23.1.1:851",
|
||||
"SymbolPath": "GVL.Counter", "DataType": "Int32", "Writable": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AMS address form
|
||||
|
||||
`HostAddress` is an `ads://{netId}:{port}` URI parsed by
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs`. The Net ID
|
||||
is six dot-separated octets (NOT an IP — a Beckhoff-specific identifier the
|
||||
router maps to a route); the port is the AMS service port (851 = TC3 PLC runtime
|
||||
1, 852 = runtime 2, 801 / 811 / 821 = TC2 PLC runtimes). Port defaults to 851
|
||||
when omitted (`ads://5.23.91.23.1.1`).
|
||||
|
||||
### Symbol path form
|
||||
|
||||
Symbol paths are parsed by
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs`, which
|
||||
mirrors IEC 61131-3 structured-text identifiers: global-variable-list
|
||||
(`GVL.Counter`), program variable (`MAIN.bStart`), struct member access
|
||||
(`Motor1.Status.Running`), array subscripts (`Data[5]`, `Matrix[1,2]`), and
|
||||
bit-access (`Flags.0`).
|
||||
|
||||
## Tag discovery
|
||||
|
||||
`DiscoverAsync` always emits the pre-declared `Tags` as the authoritative config
|
||||
path, under `TwinCAT/{device}/`. When `EnableControllerBrowse` is set, the
|
||||
driver also walks each device's symbol table and surfaces controller-resident
|
||||
globals / program locals under a `Discovered/` sub-folder; any symbol-loader
|
||||
error falls back to pre-declared-only so a flaky symbol download never blocks
|
||||
discovery.
|
||||
|
||||
## Capability surface
|
||||
|
||||
`TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`).
|
||||
|
||||
| Capability | Path | Notes |
|
||||
|------------|------|-------|
|
||||
| `IReadable` | `ReadAsync` → ADS `ReadValueAsync` | Per-device client, lazily connected and serialized per device |
|
||||
| `IWritable` | `WriteAsync` → ADS `WriteValueAsync` | Read-only tags return `BadNotWritable` |
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Pre-declared tags + opt-in controller symbol browse |
|
||||
| `ISubscribable` | native ADS notifications (default), poll fallback | `UseNativeNotifications=true` registers device notifications so the PLC pushes changes; `false` uses the shared `PollGroupEngine` |
|
||||
| `IHostConnectivityProbe` | per-device probe loop | One `HostConnectivityStatus` per configured device; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
|
||||
| `IPerCallHostResolver` | `ResolveHost` lookup in the tag map | Routes each call to the device of the referenced tag; returns an empty-string sentinel when unresolved |
|
||||
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded` so the address space is rebuilt |
|
||||
|
||||
### Rediscovery on PLC re-download
|
||||
|
||||
`IRediscoverable` is the distinguishing capability. When the ADS client detects
|
||||
`DeviceSymbolVersionInvalid` (1809 / 0x0711) — the documented TwinCAT
|
||||
symbol-version-changed signal, raised when a PLC program is re-downloaded —
|
||||
every symbol and notification handle is invalidated. The driver raises
|
||||
`OnRediscoveryNeeded` with a `TwinCAT` scope hint so Core rebuilds the address
|
||||
space rather than treating it as a transient connection error.
|
||||
|
||||
### Native notifications
|
||||
|
||||
By default the driver registers native ADS device notifications: the PLC pushes
|
||||
value changes on its own cycle, which is strictly better for latency and CPU
|
||||
than polling. `NotificationMaxDelayMs` lets TwinCAT coalesce notifications up to
|
||||
a batching delay for high-churn signals. Set `UseNativeNotifications=false` for
|
||||
deployments where the AMS router has notification limits you can't raise — then
|
||||
the driver falls through to the shared poll engine.
|
||||
|
||||
## Single-connection-per-device
|
||||
|
||||
Each device's ADS client is lazily connected and serialized by a per-device
|
||||
connect gate, so a concurrent read / write / probe can't race a client
|
||||
create-or-dispose. Probe-initiated connects use the probe timeout; reads and
|
||||
writes use the driver-wide `Timeout`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` cover
|
||||
the AMS / symbol-path parsers, the status mapper, and the driver lifecycle via
|
||||
a fake ADS client factory.
|
||||
- **Integration fixture** — see
|
||||
[TwinCAT-Test-Fixture.md](TwinCAT-Test-Fixture.md) for the harness map.
|
||||
- **CLI** — [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) documents the
|
||||
standalone read/write/browse/probe CLI for manual checks.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [`docs/v2/driver-specs.md §6`](../v2/driver-specs.md) — full per-field spec and
|
||||
DriverConfig JSON shape
|
||||
- [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) — standalone TwinCAT driver CLI
|
||||
- [TwinCAT-Test-Fixture.md](TwinCAT-Test-Fixture.md) — test-harness map
|
||||
@@ -0,0 +1,716 @@
|
||||
# Akka Hosting Alignment — Gap Closeout Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Close the four real/cosmetic gaps identified by the audit of `docs/plans/2026-05-26-akka-hosting-alignment-plan.md` so the v2 implementation matches the plan's literal contract (per-role appsettings overlays, explicit dual-endpoint visibility test, plan-prescribed filenames, removal of empty legacy directories).
|
||||
|
||||
**Architecture:** Additive only. No production-runtime semantics change. One small extension to `OpcUaApplicationHost` so the OPC UA server can advertise peer URIs in `Server.ServerArray` — gated on a new option, defaults to old behavior. Everything else is JSON, test code, file moves, and `rm -rf` of stale bin/obj trees.
|
||||
|
||||
**Tech Stack:** .NET 10, OPCFoundation .NET Standard SDK (`Opc.Ua.*`), xunit.v3, Shouldly, EF Core 10 (inherited; no schema changes).
|
||||
|
||||
**Source plan:** `docs/plans/2026-05-26-akka-hosting-alignment-plan.md`. The audit findings closed by this plan map to Tasks 54, 59, 60, and the post-Task-56 cosmetic cleanup. **Read the source plan's "Conventions for every task" block — those rules still apply here.**
|
||||
|
||||
**Branch:** `v2-gap-closeout` off `master`.
|
||||
|
||||
---
|
||||
|
||||
## Conventions for every task
|
||||
|
||||
- **Branch:** Stay on `v2-gap-closeout`. Never commit to `master` while plan is running.
|
||||
- **Build command:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — must be green before commit.
|
||||
- **Test command:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build` — relevant new/changed tests must pass.
|
||||
- **Commit format:** Conventional Commits matching the source plan — `feat(host):`, `test(opcua):`, `chore(cleanup):`, `refactor(test):`, etc.
|
||||
- **Mac compatibility:** All code must build on macOS. The new dual-endpoint test boots two real OPC UA servers on loopback — works on macOS (no Windows-only APIs needed; PKI is created under a per-test temp dir).
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Add three role-overlay appsettings files (Task 54 gap)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json`
|
||||
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json`
|
||||
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json`
|
||||
|
||||
**Background:**
|
||||
`Program.cs` line 33-35 loads `appsettings.{role-suffix}.json` where the suffix is the roles joined alphabetically with `'-'`. Today the loader passes `optional: true`, so the host boots without these files — but the source plan (Task 54) called them out as required scaffolding so operators have per-role tunable defaults.
|
||||
|
||||
Suffix matrix:
|
||||
| `OTOPCUA_ROLES` env | Loaded file |
|
||||
|---|---|
|
||||
| `admin` | `appsettings.admin.json` |
|
||||
| `driver` | `appsettings.driver.json` |
|
||||
| `admin,driver` (any order) | `appsettings.admin-driver.json` (joined alphabetical) |
|
||||
|
||||
**Step 1: Create `appsettings.admin.json`**
|
||||
|
||||
Admin-only nodes don't bind drivers; tighten Serilog and disable the LDAP dev stub by default.
|
||||
|
||||
```json
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Akka": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create `appsettings.driver.json`**
|
||||
|
||||
Driver-only nodes have no Admin UI; raise OPC UA verbosity slightly so per-node diagnostics flow to logs.
|
||||
|
||||
```json
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Opc.Ua": "Debug",
|
||||
"Akka": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create `appsettings.admin-driver.json`**
|
||||
|
||||
Combined-role nodes (the docker-dev compose default + the integration test harness) — turn on both surfaces with shared defaults.
|
||||
|
||||
```json
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Opc.Ua": "Information",
|
||||
"Akka": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Build green check**
|
||||
|
||||
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
||||
Expected: succeeds. (JSON files do not break the build; this is a smoke check that nothing else regressed.)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json
|
||||
git commit -m "feat(host): add per-role appsettings overlays for admin/driver/admin-driver"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend `OpcUaApplicationHost` with `PeerApplicationUris` + populate `Server.ServerArray`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0, Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs` (add option + post-start population)
|
||||
- Test: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs`
|
||||
|
||||
**Background:**
|
||||
The source plan's Task 60 promised a test where "real OPCFoundation client → both endpoints visible in ServerUriArray". That requires production code to populate the peer URIs onto each server's `Server.ServerArray` (NodeId i=2254) property. No such code exists in v2 today — this task adds it as an opt-in option so existing single-node tests keep their current behavior. Task 3 then writes the integration test that drives it across two servers.
|
||||
|
||||
**Step 1: Write the failing unit test**
|
||||
|
||||
Create `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Audit gap closeout — verifies <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>
|
||||
/// is reflected in <c>Server.ServerArray</c> after start. Single-server in-process check; the
|
||||
/// cross-server visibility check lives in <c>OtOpcUa.OpcUaServer.IntegrationTests</c>.
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHostServerArrayTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ServerArray_contains_local_uri_and_configured_peers_after_start()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.UnitTest",
|
||||
ApplicationUri = "urn:OtOpcUa.UnitTest.NodeA",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = pkiRoot,
|
||||
PeerApplicationUris = new[] { "urn:OtOpcUa.UnitTest.NodeB" },
|
||||
};
|
||||
|
||||
var server = new StandardServer();
|
||||
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await host.StartAsync(server, CancellationToken.None);
|
||||
|
||||
var serverArray = server.CurrentInstance.ServerObject.ServerArray.Value;
|
||||
serverArray.ShouldNotBeNull();
|
||||
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeA");
|
||||
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeB");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run the test — confirm it fails**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"`
|
||||
Expected: FAIL with `PeerApplicationUris` not found (compile error) — the option doesn't exist yet.
|
||||
|
||||
**Step 3: Add the option**
|
||||
|
||||
Edit `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`. Add to `OpcUaApplicationHostOptions` (after `AutoAcceptUntrustedClientCertificates`, around line 65):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
|
||||
/// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
|
||||
/// deployments so OPC UA clients can discover the partner endpoint via the standard
|
||||
/// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
|
||||
/// is always element 0.
|
||||
/// </summary>
|
||||
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
|
||||
```
|
||||
|
||||
**Step 4: Populate `Server.ServerArray` after start**
|
||||
|
||||
Edit `OpcUaApplicationHost.StartAsync` (around line 100-118). After the `_application.Start(server)` call and before the log line, insert:
|
||||
|
||||
```csharp
|
||||
PopulateServerArray();
|
||||
```
|
||||
|
||||
Then add the private method below `AttachUserAuthenticator`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Writes the union of <see cref="OpcUaApplicationHostOptions.ApplicationUri"/> and
|
||||
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> to the OPC UA standard
|
||||
/// <c>Server.ServerArray</c> property (NodeId i=2254). Clients in a warm-redundancy
|
||||
/// deployment discover the partner endpoint by reading this property.
|
||||
/// </summary>
|
||||
private void PopulateServerArray()
|
||||
{
|
||||
var serverObject = _server?.CurrentInstance?.ServerObject;
|
||||
if (serverObject is null) return;
|
||||
|
||||
var uris = new List<string> { _options.ApplicationUri };
|
||||
foreach (var peer in _options.PeerApplicationUris)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
|
||||
uris.Add(peer);
|
||||
}
|
||||
serverObject.ServerArray.Value = uris.ToArray();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Run the test — confirm it passes**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"`
|
||||
Expected: PASS. If `ServerObject.ServerArray.Value` is read-only (some SDK versions guard it), fall back to writing through `ServerArrayNode.Value` via the address-space accessor — but try the direct write first; the SDK exposes it as a settable BaseDataVariableState on `ServerObjectState`.
|
||||
|
||||
**Step 6: Run full OpcUaServer.Tests suite to confirm no regression**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests`
|
||||
Expected: all tests pass — `PopulateServerArray` is additive when `PeerApplicationUris` is empty (default), so existing tests don't change behavior.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs
|
||||
git commit -m "feat(opcua): OpcUaApplicationHost publishes peer URIs in Server.ServerArray"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `OtOpcUa.OpcUaServer.IntegrationTests` project
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 5, Task 6 (file moves elsewhere)
|
||||
**Depends on:** none (csproj is self-contained)
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj`
|
||||
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx` (add the project)
|
||||
|
||||
**Background:**
|
||||
The source plan's Task 60 named this exact project. Audit found ServiceLevel coverage relocated to other test projects but no `OpcUaServer.IntegrationTests` project exists. Creating the project skeleton in its own task keeps Task 3's commit focused on the test code.
|
||||
|
||||
**Step 1: Create the csproj**
|
||||
|
||||
Mirror the conventions in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj`. The integration project needs the `Opc.Ua.Client` package (vs. only `Opc.Ua.Server` in the unit tests) — confirm the version against the existing client CLI's csproj: `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj`.
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests</RootNamespace>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
```
|
||||
|
||||
If `OPCFoundation.NetStandard.Opc.Ua.Client` isn't in `Directory.Packages.props`, add it there (mirror the existing `OPCFoundation.NetStandard.Opc.Ua.Server` version exactly).
|
||||
|
||||
**Step 2: Add project to the solution**
|
||||
|
||||
Run: `dotnet sln ZB.MOM.WW.OtOpcUa.slnx add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj`
|
||||
Expected: "Project added to the solution."
|
||||
|
||||
**Step 3: Build green check**
|
||||
|
||||
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
||||
Expected: builds. (Empty project, so no test discovery yet — `dotnet test` would say "no tests".)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ \
|
||||
ZB.MOM.WW.OtOpcUa.slnx \
|
||||
Directory.Packages.props # only if the Opc.Ua.Client version was added there
|
||||
git commit -m "test(opcua): scaffold OtOpcUa.OpcUaServer.IntegrationTests project"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `DualEndpointTests` — real OPC UA client reads both URIs from `Server.ServerArray`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 5, Task 6
|
||||
**Depends on:** Task 1 (PeerApplicationUris wiring), Task 2 (IT project exists)
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs`
|
||||
|
||||
**Background:**
|
||||
This is the explicit Task 60 deliverable: a real OPC UA client connects to one server and confirms it can discover the partner via `Server.ServerArray`. Single-server unit-side coverage exists in Task 1; this test exercises the wire path with both servers up.
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```csharp
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Source plan Task 60 — closes the audit gap. Boots two real <see cref="StandardServer"/>
|
||||
/// instances on loopback, each configured with the other's <c>ApplicationUri</c> in
|
||||
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>. A real OPC UA client connects
|
||||
/// to Node A, reads <c>Server.ServerArray</c>, and asserts both URIs are visible — the
|
||||
/// warm-redundancy discovery contract clients depend on.
|
||||
/// </summary>
|
||||
public sealed class DualEndpointTests
|
||||
{
|
||||
private const string NodeAUri = "urn:OtOpcUa.DualEndpoint.NodeA";
|
||||
private const string NodeBUri = "urn:OtOpcUa.DualEndpoint.NodeB";
|
||||
|
||||
[Fact]
|
||||
public async Task Client_reads_both_ApplicationUris_from_NodeA_ServerArray()
|
||||
{
|
||||
var pkiRootA = Path.Combine(Path.GetTempPath(), $"otopcua-pki-a-{Guid.NewGuid():N}");
|
||||
var pkiRootB = Path.Combine(Path.GetTempPath(), $"otopcua-pki-b-{Guid.NewGuid():N}");
|
||||
var portA = AllocateFreePort();
|
||||
var portB = AllocateFreePort();
|
||||
|
||||
try
|
||||
{
|
||||
await using var nodeA = await StartNodeAsync(NodeAUri, portA, pkiRootA, peers: new[] { NodeBUri });
|
||||
await using var nodeB = await StartNodeAsync(NodeBUri, portB, pkiRootB, peers: new[] { NodeAUri });
|
||||
|
||||
var serverArray = await ReadServerArrayAsync($"opc.tcp://127.0.0.1:{portA}/OtOpcUa");
|
||||
serverArray.ShouldContain(NodeAUri);
|
||||
serverArray.ShouldContain(NodeBUri);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(pkiRootA)) Directory.Delete(pkiRootA, recursive: true);
|
||||
if (Directory.Exists(pkiRootB)) Directory.Delete(pkiRootB, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<OpcUaApplicationHost> StartNodeAsync(
|
||||
string applicationUri, int port, string pkiRoot, string[] peers)
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = applicationUri, // unique per node — SDK uses it for cert CN
|
||||
ApplicationUri = applicationUri,
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = pkiRoot,
|
||||
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
PeerApplicationUris = peers,
|
||||
};
|
||||
var server = new StandardServer();
|
||||
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await host.StartAsync(server, CancellationToken.None);
|
||||
return host;
|
||||
}
|
||||
|
||||
private static async Task<string[]> ReadServerArrayAsync(string endpointUrl)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUa.DualEndpointClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier(),
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
CertificateValidator = new CertificateValidator(),
|
||||
};
|
||||
await appConfig.Validate(ApplicationType.Client);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var endpoint = CoreClientUtils.SelectEndpoint(appConfig, endpointUrl, useSecurity: false);
|
||||
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||
|
||||
using var session = await Session.Create(
|
||||
appConfig, configuredEndpoint, updateBeforeConnect: false,
|
||||
sessionName: "DualEndpointTests", sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()),
|
||||
preferredLocales: null);
|
||||
|
||||
var value = session.ReadValue(VariableIds.Server_ServerArray);
|
||||
return (string[])value.Value;
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run the test**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests`
|
||||
Expected: PASS. Wall-time ~3-5 s (two cert-creation cycles + session handshake).
|
||||
|
||||
If the test hangs on the session handshake on first run, it's the SDK reading the trusted-cert store — bumping `AutoAcceptUntrustedClientCertificates = true` on both server hosts (already set above) should resolve it. If `CoreClientUtils.SelectEndpoint` throws because the SDK version uses a different overload, fall back to constructing the `EndpointDescription` directly with `EndpointUrl = endpointUrl, SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None` and skipping `SelectEndpoint`.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs
|
||||
git commit -m "test(opcua): DualEndpointTests — real client reads peer URIs from Server.ServerArray"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire `OtOpcUa.OpcUaServer.IntegrationTests` into v2-ci.yml
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 5, Task 6
|
||||
**Depends on:** Task 3 (project must exist + have a real test before CI runs it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/.github/workflows/v2-ci.yml`
|
||||
|
||||
**Step 1: Add the project to the `integration` job**
|
||||
|
||||
Either extend the existing `integration` job to run a second `dotnet test` step, or convert it to a matrix. Prefer a matrix for symmetry with `unit-tests`:
|
||||
|
||||
Open `.github/workflows/v2-ci.yml`, locate the `integration:` job. Replace it with:
|
||||
|
||||
```yaml
|
||||
integration:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project:
|
||||
- tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests
|
||||
- tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- name: dotnet test ${{ matrix.project }}
|
||||
run: dotnet test ${{ matrix.project }} --configuration Release --filter "Category!=E2E"
|
||||
```
|
||||
|
||||
**Step 2: Build green check**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests --configuration Release --filter "Category!=E2E"`
|
||||
Expected: matches the exact CI command — passes locally so CI will pass too.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/v2-ci.yml
|
||||
git commit -m "ci(v2): include OpcUaServer.IntegrationTests in integration matrix"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Rename `FailoverScenarioTests` → `FailoverDuringDeployTests` (Task 59 cosmetic)
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** Task 0, Task 1, Task 2, Task 6 (different files)
|
||||
|
||||
**Files:**
|
||||
- Rename: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs` → `FailoverDuringDeployTests.cs`
|
||||
- Modify: class name + namespace-internal references
|
||||
|
||||
**Step 1: Rename the file and the class**
|
||||
|
||||
```bash
|
||||
git mv tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs
|
||||
```
|
||||
|
||||
Then edit `FailoverDuringDeployTests.cs` and replace the single class declaration `public sealed class FailoverScenarioTests` with `public sealed class FailoverDuringDeployTests`. Use Edit, not sed — the file only declares this class once (`grep -c "FailoverScenario" .` ≤ 2).
|
||||
|
||||
**Step 2: Sweep for any stale references**
|
||||
|
||||
Run: `grep -rln "FailoverScenarioTests" .`
|
||||
Expected: zero matches after Step 1. If anything appears (e.g., a CI filter, a doc), fix the reference.
|
||||
|
||||
**Step 3: Build + run test**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~FailoverDuringDeployTests"`
|
||||
Expected: same tests pass that previously passed under the old name.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs
|
||||
git commit -m "refactor(test): rename FailoverScenarioTests → FailoverDuringDeployTests for plan parity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Delete empty bin/obj-only legacy directories
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** Task 0, Task 1, Task 2, Task 5
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Server/`
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/`
|
||||
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/`
|
||||
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/`
|
||||
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/`
|
||||
|
||||
**Background:**
|
||||
Source plan Task 56 deleted the projects from `ZB.MOM.WW.OtOpcUa.slnx` (confirmed by the audit) but left `bin/`+`obj/` shells on disk. These confuse new contributors and skew directory listings. None of them are referenced anywhere.
|
||||
|
||||
**Step 1: Sanity-check that each directory is bin/obj-only**
|
||||
|
||||
```bash
|
||||
for dir in \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Server \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Admin \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests; do
|
||||
echo "--- $dir ---"
|
||||
find "$dir" -maxdepth 2 -type f | grep -v "/bin/\|/obj/"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: every section is empty (no source files leak out). If any source file shows, STOP and surface it — don't delete blindly.
|
||||
|
||||
**Step 2: Verify slnx doesn't reference them**
|
||||
|
||||
Run: `grep -nE 'ZB\.MOM\.WW\.OtOpcUa\.(Server|Admin)(/|\.Tests|\.E2ETests)' ZB.MOM.WW.OtOpcUa.slnx`
|
||||
Expected: zero matches.
|
||||
|
||||
**Step 3: Delete the directories**
|
||||
|
||||
```bash
|
||||
rm -rf src/Server/ZB.MOM.WW.OtOpcUa.Server \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Admin \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests
|
||||
```
|
||||
|
||||
**Step 4: Build green check**
|
||||
|
||||
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
||||
Expected: succeeds (these directories were already out of the solution).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(cleanup): remove stale bin/obj shells for deleted v1 Server/Admin projects"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Final build + test green check
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (verification, depends on all prior tasks)
|
||||
|
||||
**Step 1: Restore + build**
|
||||
|
||||
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
||||
Expected: 0 errors, 0 warnings (TreatWarningsAsErrors is on across the solution).
|
||||
|
||||
**Step 2: Run the full test suite**
|
||||
|
||||
Run: `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build`
|
||||
Expected: all tests green. Specifically confirm:
|
||||
- `OpcUaApplicationHostServerArrayTests` (Task 1) — pass
|
||||
- `DualEndpointTests` (Task 3) — pass
|
||||
- `FailoverDuringDeployTests` (Task 5) — same count of tests pass as before the rename
|
||||
|
||||
**Step 3: Smoke check the audit assertions**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
ls src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.*.json
|
||||
find tests/Server -iname "DualEndpointTests.cs" -o -iname "FailoverDuringDeployTests.cs"
|
||||
ls -la src/Server/ZB.MOM.WW.OtOpcUa.{Server,Admin} 2>/dev/null
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 4 appsettings files: `.json`, `.Development.json`, `.admin.json`, `.admin-driver.json`, `.driver.json`
|
||||
- Both renamed/new test files exist
|
||||
- The two `ls -la` calls return errors (directories gone)
|
||||
|
||||
**Step 4: No commit unless cleanup turned up**
|
||||
|
||||
If anything failed in Steps 1-3, fix it as a follow-up task — do not paper over with a `--no-verify` commit.
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
After Task 7:
|
||||
|
||||
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green
|
||||
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build` — green (incl. 2 new tests)
|
||||
3. `git log --oneline master..HEAD` — exactly 6 commits, Conventional-Commits style
|
||||
4. Open PR `v2-gap-closeout` → `master` titled "v2: close audit gaps — appsettings overlays, DualEndpointTests, cleanup"
|
||||
|
||||
---
|
||||
|
||||
## Task index
|
||||
|
||||
| # | Title | Class | Time | Parallel with |
|
||||
|---|---|---|---|---|
|
||||
| 0 | Per-role appsettings overlays | small | 3m | 1, 5, 6 |
|
||||
| 1 | OpcUaApplicationHost.PeerApplicationUris + ServerArray | standard | 5m | 0, 5, 6 |
|
||||
| 2 | OpcUaServer.IntegrationTests project skeleton | small | 4m | 5, 6 |
|
||||
| 3 | DualEndpointTests | standard | 5m | 5, 6 |
|
||||
| 4 | CI matrix entry for new IT project | small | 3m | 5, 6 |
|
||||
| 5 | Rename FailoverScenarioTests → FailoverDuringDeployTests | trivial | 2m | 0, 1, 2, 6 |
|
||||
| 6 | Delete stale bin/obj-only directories | trivial | 2m | 0, 1, 2, 5 |
|
||||
| 7 | Final build + test green check | trivial | 3m | none |
|
||||
|
||||
**Total estimated subagent time:** ~27 min.
|
||||
|
||||
**Dependency graph (non-parallel pairs):**
|
||||
- Task 3 depends on Task 1 (option must exist) and Task 2 (project must exist)
|
||||
- Task 4 depends on Task 3 (CI runs the project's tests)
|
||||
- Task 7 depends on all prior tasks
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 0: Per-role appsettings overlays", "status": "completed", "commit": "898a477"},
|
||||
{"id": 2, "subject": "Task 1: OpcUaApplicationHost.PeerApplicationUris + ServerArray population", "status": "completed", "commits": ["70ffd28", "cb936db"]},
|
||||
{"id": 3, "subject": "Task 2: OpcUaServer.IntegrationTests project skeleton", "status": "completed", "commit": "83eda9e"},
|
||||
{"id": 4, "subject": "Task 3: DualEndpointTests — real OPC UA client reads both URIs from Server.ServerArray", "status": "completed", "commits": ["dce2528", "a5412c1", "cb936db"], "blockedBy": ["2", "3"]},
|
||||
{"id": 5, "subject": "Task 4: Wire OpcUaServer.IntegrationTests into v2-ci.yml", "status": "completed", "commit": "e8c4f18", "blockedBy": ["4"]},
|
||||
{"id": 6, "subject": "Task 5: Rename FailoverScenarioTests → FailoverDuringDeployTests", "status": "completed", "commit": "25ce111"},
|
||||
{"id": 7, "subject": "Task 6: Delete empty bin/obj-only legacy directories", "status": "completed", "commit": "(no tracked changes — bin/obj only)"},
|
||||
{"id": 8, "subject": "Task 7: Final build + test green check", "status": "completed", "blockedBy": ["1", "2", "3", "4", "5", "6", "7"]}
|
||||
],
|
||||
"lastUpdated": "2026-05-26T00:00:00Z",
|
||||
"finalReview": "approved",
|
||||
"branchHead": "e8c4f18",
|
||||
"branchCommitCount": 8
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
# AdminUI — Driver-Specific Pages
|
||||
|
||||
**Status:** Design approved, ready for implementation planning
|
||||
**Date:** 2026-05-28
|
||||
**Branch:** `master` (work to land on a feature branch)
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
Today the AdminUI has a single generic `DriverEdit.razor` page (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`, 323 lines) that edits every driver type via a raw JSON `DriverConfig` textarea. The page itself flags this as temporary:
|
||||
|
||||
> Per Q1 of the AdminUI rebuild plan, typed driver editors (Modbus, FOCAS) are deferred… lands in a Phase C.2 follow-up.
|
||||
|
||||
This design is that follow-up. Goals:
|
||||
|
||||
1. Replace the JSON blob with a **typed form per driver type** that exposes every supported configuration option.
|
||||
2. Add three driver-aware operator capabilities to each page: **Test Connect**, **live runtime status**, and a **driver-specific tag/address picker**.
|
||||
3. Add **Reconnect / Restart** controls on the status panel for authorized users.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
All 9 driver types ship typed pages in this work:
|
||||
|
||||
```
|
||||
ModbusTcp, AbCip, AbLegacy, S7, TwinCat, FOCAS,
|
||||
OpcUaClient, Galaxy, Historian.Wonderware
|
||||
```
|
||||
|
||||
Each typed page exposes the full surface of its driver's options class — the JSON editor is retired; the typed form is the only way to edit driver config from the AdminUI.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Project layout
|
||||
|
||||
```
|
||||
src/Drivers/
|
||||
ZB.MOM.WW.OtOpcUa.Driver.<Type>/ (runtime, unchanged behavior)
|
||||
ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts/ NEW — POCO options + DataAnnotations only
|
||||
<Type>DriverOptions.cs (moved from runtime project)
|
||||
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/
|
||||
Components/Pages/Clusters/Drivers/ NEW folder
|
||||
DriverTypePicker.razor route: /clusters/{id}/drivers/new
|
||||
DriverEditRouter.razor route: /clusters/{id}/drivers/{instanceId}
|
||||
ModbusDriverPage.razor route: /clusters/{id}/drivers/new/modbus
|
||||
GalaxyDriverPage.razor route: /clusters/{id}/drivers/new/galaxy
|
||||
S7DriverPage.razor
|
||||
OpcUaClientDriverPage.razor
|
||||
AbCipDriverPage.razor
|
||||
AbLegacyDriverPage.razor
|
||||
TwinCatDriverPage.razor
|
||||
FocasDriverPage.razor
|
||||
HistorianWonderwareDriverPage.razor
|
||||
Components/Shared/Drivers/ NEW folder
|
||||
DriverFormShell.razor panel layout + Save/Cancel/Delete
|
||||
DriverIdentitySection.razor InstanceId, Name, Namespace, Enabled
|
||||
DriverResilienceSection.razor Polly overrides
|
||||
DriverStatusPanel.razor live status + Reconnect/Restart
|
||||
DriverTestConnectButton.razor per-driver-timeout probe
|
||||
DriverTagPicker.razor modal shell, hosts per-driver picker body
|
||||
Hubs/
|
||||
DriverStatusHub.cs NEW SignalR hub at /hubs/driverstatus
|
||||
DriverStatusSignalRBridge.cs NEW (mirrors FleetStatusSignalRBridge)
|
||||
```
|
||||
|
||||
### 3.2 Routing
|
||||
|
||||
- `/clusters/{ClusterId}/drivers` — existing `ClusterDrivers.razor` list, unchanged.
|
||||
- `/clusters/{ClusterId}/drivers/new` — new `DriverTypePicker.razor` (operator picks driver type).
|
||||
- `/clusters/{ClusterId}/drivers/new/{driverType}` — typed new-form for that type.
|
||||
- `/clusters/{ClusterId}/drivers/{DriverInstanceId}` — `DriverEditRouter.razor` reads the row's `DriverType`, dispatches to the right `*DriverPage` via `<DynamicComponent>` (no redirect flicker).
|
||||
|
||||
### 3.3 Schema source
|
||||
|
||||
Each driver's `Options` class moves to a new `Driver.<Type>.Contracts` csproj — POCO + `System.ComponentModel.DataAnnotations` attributes only, no NuGet references, no project references. The runtime driver project adds a `ProjectReference` to its contracts sibling and re-uses the same type (single source of truth, no `TypeForwardedTo` needed if the namespace is preserved). The AdminUI gains 9 `ProjectReference`s — all pure POCO, so no native deps (Galaxy COM, FOCAS native libs, OPC UA stack) leak into the AdminUI publish output.
|
||||
|
||||
Attributes used:
|
||||
|
||||
- `[Required]`, `[Range(...)]`, `[RegularExpression(...)]` — render as inputs + `<ValidationMessage>` via `DataAnnotationsValidator`.
|
||||
- `[Display(Name, Description, GroupName)]` — label, help-text under field, panel section.
|
||||
- `[DataType(DataType.Password)]` — render as `<InputText type="password">` (e.g. mxaccessgw API key).
|
||||
|
||||
The `*DriverPage.razor` files **explicitly** bind each field (no runtime reflection). Attributes drive labels/help/validation but not field discovery — this avoids the "metadata silently drifts from rendering" trap.
|
||||
|
||||
### 3.4 Persistence
|
||||
|
||||
`DriverInstance.DriverConfig` stays a JSON string column (no schema change). On save: typed form-model serialized via `System.Text.Json` against the driver's Options class. On load: row's JSON deserialized into the matching Options class with `JsonSerializerOptions { UnmappedMemberHandling = Skip }` so old/unknown fields are silently dropped on next save. Version skew is bounded by the fact that drivers ship as one host binary.
|
||||
|
||||
`RowVersion` optimistic concurrency unchanged from today's `DriverEdit.razor`.
|
||||
|
||||
## 4. Test Connect
|
||||
|
||||
### 4.1 Flow
|
||||
|
||||
```
|
||||
[Browser] [AdminUI server] [Cluster]
|
||||
|
||||
DriverGalaxyPage AdminProbeService AdminOperationsActor
|
||||
| | |
|
||||
|-- click TestConnect --->| |
|
||||
| |-- Ask<TestDriverConnect> --->|
|
||||
| | (driverType, configJson, |
|
||||
| | timeoutSecs) |
|
||||
| | |--> spawn transient probe actor
|
||||
| | | (resolves IDriverProbe by
|
||||
| | | driverType via DI)
|
||||
| | |<-- ProbeResult (ok, latencyMs)
|
||||
| |<-- TestDriverConnectResult --|
|
||||
|<-- green / red chip ----|
|
||||
```
|
||||
|
||||
### 4.2 Components
|
||||
|
||||
- **`IDriverProbe`** — interface in `Core.Abstractions` (or equivalent). One implementation per driver type, lives in the driver's **runtime** project. Reuses the existing `IHostConnectivityProbe` plumbing where present (FOCAS, TwinCAT confirmed). For drivers without one, the probe is a cheap subset of the real connect path: TCP `SocketAsyncOperations` for Modbus/AbCip/S7, session open+close for OpcUaClient, `MxCommand.Ping` for Galaxy. Probes **never write**.
|
||||
- **`TestDriverConnect` message** in `Commons/Messages/Admin` — `(string driverType, string configJson, TimeSpan timeout)`. Handler in `AdminOperationsActor`: resolves the right probe via keyed DI (`IServiceProvider.GetRequiredKeyedService<IDriverProbe>(driverType)`), deserializes JSON into the matching Options class, calls `probe.RunAsync(options, ct)`. Returns `TestDriverConnectResult(bool ok, string? message, TimeSpan? latency)`.
|
||||
- **`AdminProbeService`** (AdminUI side) — thin wrapper around the existing AdminOperationsActor bridge. Caller passes timeout; service enforces a 60s hard backstop.
|
||||
- **`<DriverTestConnectButton>`** — accepts driver type + `Func<string>` to build form JSON on-click. Renders button + inline result chip (auto-clears after 30s). Disabled while in-flight.
|
||||
|
||||
### 4.3 Timeout
|
||||
|
||||
Each driver's Options class exposes a `ProbeTimeout` (`TimeSpan` or `int Seconds`) with a driver-appropriate default — e.g. Modbus 5s, OpcUaClient 15s, Galaxy 30s. The button reads from the live form (not the persisted row), so an operator can override the timeout per probe attempt. Server-side max = 60s.
|
||||
|
||||
### 4.4 Safety
|
||||
|
||||
- Probe spawns a transient actor with the *form's* config — the live driver actor (using the *persisted* config) is untouched.
|
||||
- Probe never mutates the live driver or the database.
|
||||
- Probe inherits the user context via the existing AdminOperationsActor audit-log entry.
|
||||
|
||||
## 5. Live Status Panel
|
||||
|
||||
### 5.1 Flow
|
||||
|
||||
```
|
||||
DriverActor DriverStatusSignalRBridge DriverStatusPanel (browser)
|
||||
| ^ |
|
||||
|-- publishes |-- subscribed in |
|
||||
| DriverHealthChanged | OnInitializedAsync |
|
||||
| to event stream | with InstanceId filter |
|
||||
| | |
|
||||
| |-- pushes update -------------->|
|
||||
| |
|
||||
| |-- renders state chip,
|
||||
| | last-success, error count,
|
||||
| | Reconnect/Restart buttons
|
||||
```
|
||||
|
||||
### 5.2 Reused infrastructure
|
||||
|
||||
Driver actors already maintain `DriverHealth(state, lastSuccessUtc, lastError)` — confirmed in FOCAS (`FocasDriver.cs`) and TwinCAT. The bridge mirrors the existing `FleetStatusSignalRBridge` + `AlertSignalRBridge` pattern. SignalR hub uses the same cookie-auth as existing hubs.
|
||||
|
||||
### 5.3 New components
|
||||
|
||||
- **`DriverStatusHub`** — single method `JoinDriver(string driverInstanceId)`, adds connection to a per-instance group and immediately replies with the current snapshot.
|
||||
- **`DriverStatusSignalRBridge`** — subscribes to per-cluster driver-health event stream, fans out into SignalR groups keyed by `driverInstanceId`. Only running drivers publish; `Enabled=false` instances render "Disabled — not deployed" without subscribing.
|
||||
- **`<DriverStatusPanel>`** — props `DriverInstanceId`, `Enabled`. Opens hub on init, calls `JoinDriver`, registers `On<StatusSnapshot>("status", ...)`. Renders state chip (`Healthy` / `Connecting` / `Faulted` / `Unknown`) + last-success timestamp ("2s ago") + error count over last 5min + last error message (collapsed, expandable). Disposes hub on dispose.
|
||||
|
||||
### 5.4 Reconnect / Restart controls
|
||||
|
||||
Two buttons on the status panel:
|
||||
|
||||
- **Reconnect** — driver actor closes + reopens its transport, keeps actor alive. Fast, idempotent. No confirm dialog.
|
||||
- **Restart** — full actor stop + respawn, loses in-memory state. Slower, can interrupt active subscriptions. Confirm dialog required.
|
||||
|
||||
Both:
|
||||
|
||||
- Gated by authorization policy `DriverOperator` (mapped to an LDAP group via existing `Authentication.Ldap` config). **Hidden** (not just disabled) for unauthorized users — same approach as other AdminUI gated actions.
|
||||
- Dispatch `RestartDriver` / `ReconnectDriver` messages through `AdminOperationsActor`, which audit-logs each operation.
|
||||
- Show spinner + inline "Reconnecting…" chip; panel reflects new state via the SignalR push once health changes.
|
||||
- Disabled when `Enabled=false` (nothing to restart) and during any in-flight Test Connect on the same page.
|
||||
|
||||
### 5.5 Out of scope this PR
|
||||
|
||||
History graphs (latency/error rate over time), deep diagnostics (per-tag last values, queue depths), and per-driver bespoke controls beyond Reconnect/Restart — all follow-ups.
|
||||
|
||||
### 5.6 Edge cases
|
||||
|
||||
- **Driver not yet deployed** (row exists, `Enabled=true`, cluster hasn't picked it up) — panel shows "Awaiting deployment", `DriverHealth.Unknown`.
|
||||
- **Edit page open while driver is running** — status reflects deployed config, not the form. Banner: "Showing live status for the deployed config — your unsaved changes take effect after Save → next deploy cycle."
|
||||
- **Test Connect + live status** — probe runs in a transient actor (Section 4), live status reflects the persistent actor. Don't interfere.
|
||||
|
||||
## 6. Tag / Address Picker
|
||||
|
||||
A picker slot on each page, launched as a modal so the config form stays visible behind it.
|
||||
|
||||
### 6.1 Shared shell
|
||||
|
||||
`<DriverTagPicker>` — modal chrome + search box + "use this address" action that emits a string back to the parent (e.g. `4x0001` for Modbus, `ns=2;s=Channel.Device.Tag` for OPC UA). Where the picked address lands depends on context: from "create equipment / create tag" flow, pushed into that form; standalone, copy-to-clipboard.
|
||||
|
||||
### 6.2 Per-driver bodies — first pass (all static address builders)
|
||||
|
||||
| Driver | Picker body |
|
||||
|---|---|
|
||||
| Modbus | Register-type dropdown + offset spinner + length → `4x00001-4` |
|
||||
| AbCip | Tag name + element index, PLC-family hint from form |
|
||||
| AbLegacy | File type (N/B/F/I/O/S/T/C/R) + file number + element, PLC-family-aware |
|
||||
| S7 | Area (DB/M/I/Q) + db-number + offset + S7 type → `DB10.DBD20:REAL` |
|
||||
| TwinCat | ADS variable name (free-text + format hint) |
|
||||
| FOCAS | Parameter group dropdown + parameter ID; drives FOCAS function-code lookup |
|
||||
| OpcUaClient | Static helper (NodeId free-text) — **live browse deferred** |
|
||||
| Galaxy | Static helper (tag_name.AttributeName free-text) — **live browse deferred** |
|
||||
| Historian.Wonderware | Tag name + retrieval mode + interval |
|
||||
|
||||
### 6.3 Deferred to follow-up
|
||||
|
||||
- **OpcUaClient live browse** — open session against configured endpoint, walk address space, return NodeId. Reuses the existing `Client.CLI` browse path or calls the OPC UA stack inline. Requires endpoint config to be valid (Test Connect first).
|
||||
- **Galaxy live browse** — calls mxaccessgw's `GalaxyRepository.ListObjects` / `ListAttributes` via gRPC. Returns `tag_name.AttributeName`. Reuses `IGalaxyHierarchySource`.
|
||||
- **Historian.Wonderware tag list** — pull from historian's tag store.
|
||||
|
||||
The picker slot is wired so swapping a static builder for a live browser later is a 1-component swap, not a page rewrite.
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
| Failure | Surface |
|
||||
|---|---|
|
||||
| Invalid form input | `DataAnnotationsValidator` + per-field `<ValidationMessage>`; Save disabled. |
|
||||
| `DbUpdateConcurrencyException` | Red banner — "Another user changed this driver instance, reload before re-applying." (matches existing pattern) |
|
||||
| FK violation (Namespace deleted while edit open) | Catch `DbUpdateException` — "Namespace `<id>` no longer exists in this cluster — pick another or recreate it." |
|
||||
| Probe — driver-side exception | Probe actor catches, returns `(false, ex.Message, null)`. Red chip with message. Full stack to Serilog with audit context. |
|
||||
| Probe — timeout | `(false, "Probe timed out after {n}s", null)`. Server-side 60s backstop. |
|
||||
| Probe — DI lookup fails (unknown driver type) | Defensive — `(false, "No probe registered for driver type '{type}'", null)`. Error-level log. |
|
||||
| SignalR disconnect | "Reconnecting…" chip + SignalR auto-reconnect. Stale snapshot dimmed after 30s. |
|
||||
| Reconnect/Restart on stopped driver | "Driver is not running on any node". Button re-enables. |
|
||||
| Authorization denied | Reconnect/Restart buttons hidden for unauthorized users. |
|
||||
| Corrupted `DriverConfig` JSON on row load | Yellow banner — "Saved config could not be parsed against the current schema; falling back to defaults. Save will overwrite." Original JSON preserved in banner for copy-paste. |
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Unit tests (`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend existing project, or create if absent)
|
||||
|
||||
- `DriverPageFormSerializationTests` — 9 drivers × round-trip Options ↔ JSON ↔ form ↔ DB row. Asserts no loss for known fields, unknown fields dropped silently.
|
||||
- `DriverTestConnectButtonTests` — render tests: enabled/disabled states, timeout behavior, result chip.
|
||||
- `DriverStatusPanelTests` — render snapshots for each `DriverState`, disabled mode, stale-data dim.
|
||||
- `DriverRestartReconnectAuthorizationTests` — buttons hidden without `DriverOperator` policy.
|
||||
- Address-builder unit tests per driver — 9 small suites covering canonical address formats.
|
||||
|
||||
### 8.2 Integration tests (`tests/Server/.../IntegrationTests/`)
|
||||
|
||||
- `DriverTestConnectE2eTests` — Modbus + AbCip + S7 against Docker fixtures (`lmxopcua-fix up modbus` etc.). Green probe vs sim, red probe vs wrong port, timeout vs black-holed IP.
|
||||
- `DriverReconnectE2eTests` — start a driver, click Reconnect, assert `Connecting → Healthy` transition within N seconds.
|
||||
- `DriverStatusHubE2eTests` — open hub, force state change, assert push arrives within 1s.
|
||||
|
||||
### 8.3 Manual smoke (run before PR ship)
|
||||
|
||||
Operator on the dev VM with Docker fixtures available:
|
||||
|
||||
1. Pre-flight:
|
||||
- `lmxopcua-fix up modbus standard` — Modbus sim running on `10.100.0.35:5020`.
|
||||
- AdminUI deployed and reachable.
|
||||
- LDAP user has the `DriverOperator` (or `FleetAdmin`) role.
|
||||
|
||||
2. Type picker:
|
||||
- Navigate to `/clusters/<id>/drivers/new`. Verify 9 driver-type cards render.
|
||||
- Click "ModbusTcp". Verify the typed form opens on `/clusters/<id>/drivers/new/modbustcp`.
|
||||
|
||||
3. Test Connect (form-driven, no save):
|
||||
- Fill in Host=`10.100.0.35`, Port=`5020`, leave defaults otherwise.
|
||||
- Click "Test Connect". Verify green chip + latency < 100ms.
|
||||
- Change port to `9999`. Click again. Verify red chip with "ConnectionRefused" or similar.
|
||||
- Change host to `1.2.3.4`. Click again. Within (default 5s) the chip shows "Probe timed out after 5s".
|
||||
|
||||
4. Save + edit:
|
||||
- Set valid endpoint back. Save. Verify redirect to `/clusters/<id>/drivers`.
|
||||
- Open the just-saved instance. Verify the typed form pre-populates correctly.
|
||||
|
||||
5. Live status panel:
|
||||
- In a second browser tab, open the same driver's edit page. Confirm the `DriverStatusPanel` renders state + last-update.
|
||||
- Stop the Modbus sim (`lmxopcua-fix down modbus`). Within ~30s, verify the panel transitions Healthy → Reconnecting / Faulted (depending on driver state).
|
||||
- Bring the sim back up (`lmxopcua-fix up modbus standard`). Verify Healthy is restored.
|
||||
|
||||
6. Reconnect / Restart:
|
||||
- Click "Reconnect" on the status panel. Verify a brief "Reconnecting…" chip + a Healthy state push within 5s.
|
||||
- Click "Restart". Confirm in the dialog. Verify the actor restarts (full state transition).
|
||||
- Verify both buttons are HIDDEN for an unauthorized user (LDAP user without `DriverOperator` role).
|
||||
|
||||
7. Address picker:
|
||||
- Click "Pick address" on the Modbus page. Verify the modal opens.
|
||||
- Builder: select Holding + offset=10 + length=2. Verify the chip shows `4x00010-2`. Click "Use this address" — verify it surfaces in the parent page.
|
||||
- Close the modal. Repeat for one other driver type (e.g. S7) to confirm cross-driver wiring.
|
||||
|
||||
8. Other 8 driver types — smoke each page renders:
|
||||
- Repeat steps 2–4 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an `opc.tcp://` endpoint.
|
||||
|
||||
If any step fails, record the failure mode + Razor / actor log excerpts and reopen for fix before PR ship.
|
||||
|
||||
### 8.4 bUnit harness
|
||||
|
||||
If the AdminUI tests project doesn't already use bUnit, render tests downgrade to logic-only tests on the `@code { }` block; Razor markup is covered by integration tests. Decision deferred to implementation plan.
|
||||
|
||||
## 9. Migration / Sequencing
|
||||
|
||||
Incremental — driver-by-driver swap-over. Each step compile-clean and shippable on its own:
|
||||
|
||||
1. Land 9 Contracts projects + move Options classes. No UI changes.
|
||||
2. Land shared section components (`DriverIdentitySection`, `DriverResilienceSection`, `DriverFormShell`). Wire into existing `DriverEdit.razor` first so they're tested in place.
|
||||
3. Land `DriverTypePicker` + `DriverEditRouter` + `<DynamicComponent>` dispatch.
|
||||
4. Land driver-specific pages one at a time. After each, route list-page links for that driver type only to the new page; leave others on generic editor.
|
||||
5. Delete the generic `DriverEdit.razor` + its route once all 9 typed pages exist.
|
||||
6. Land `DriverStatusHub` + bridge + `<DriverStatusPanel>` (read-only first).
|
||||
7. Land `<DriverTestConnectButton>` + `IDriverProbe` impls + AdminOperationsActor handler.
|
||||
8. Land Reconnect/Restart on the status panel with `DriverOperator` policy.
|
||||
9. Land 9 static address builders inside `<DriverTagPicker>`.
|
||||
|
||||
## 10. Out of scope (follow-ups)
|
||||
|
||||
- Live tag browse for OpcUaClient + Galaxy (Section 6.3).
|
||||
- Historian.Wonderware tag list pulled from store.
|
||||
- Status panel history graphs + per-tag diagnostics (Section 5.5).
|
||||
- Per-driver bespoke controls beyond Reconnect/Restart.
|
||||
- bUnit setup if not already present (Section 8.4) — decide during implementation planning.
|
||||
@@ -0,0 +1,840 @@
|
||||
# AdminUI Driver-Specific Pages Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace `DriverEdit.razor` (generic JSON editor) with typed per-driver pages for all 9 driver types, each with Test Connect, live runtime status panel (Reconnect/Restart), and a driver-specific tag/address picker.
|
||||
|
||||
**Architecture:** 9 new `Driver.<Type>.Contracts` csprojs hold the `Options` POCOs (moved from runtime projects). AdminUI gains 9 thin `ProjectReference`s — no native deps leak. 9 typed `*DriverPage.razor` components share `<DriverFormShell>` + section/picker/status/test components in `Components/Shared/Drivers/`. Test Connect routes through `AdminOperationsActor` to per-driver `IDriverProbe` impls. Live status uses an Akka DistributedPubSub bridge → SignalR hub → Blazor panel (same pattern as the existing `FleetStatusSignalRBridge`).
|
||||
|
||||
**Tech Stack:** .NET 10 Blazor Server, EF Core (SQL Server), Akka.NET (cluster + DistributedPubSub), SignalR, OPC Foundation OPC UA .NET Standard stack, xUnit + Shouldly.
|
||||
|
||||
**Authoritative design:** `docs/plans/2026-05-28-adminui-driver-pages-design.md`. Re-read its sections when a task references them.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Preconditions
|
||||
|
||||
### Task 0.1: Create AdminUI test project (currently absent)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (every later test task depends on this)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj`
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/_PlaceholderTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the new project)
|
||||
|
||||
**Step 1:** Create csproj targeting `net10.0`, `<IsPackable>false</IsPackable>`. Package refs: `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `Shouldly`. Project ref: `..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj`. Copy structure from a peer like `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/*.csproj`.
|
||||
|
||||
**Step 2:** Add a single `_PlaceholderTests.cs` with one passing fact so the project compiles + the test runner discovers something.
|
||||
|
||||
**Step 3:** Add `<Solution><Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj"/></Solution>` to `ZB.MOM.WW.OtOpcUa.slnx` (match the existing element style).
|
||||
|
||||
**Step 4:** Run `dotnet build tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` then `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`. Both succeed.
|
||||
|
||||
**Step 5:** Commit. `test(adminui): scaffold AdminUI.Tests project`
|
||||
|
||||
**Decision (deferred from design §8.4):** *no bUnit*. All Razor render tests degrade to logic-only tests on `@code { }` blocks. Razor markup is covered by the integration tests in Phase 6/7/8.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Contracts Projects (Driver Options → POCO-only siblings)
|
||||
|
||||
**Pattern (apply to every task in this phase):**
|
||||
|
||||
For driver type `<Type>` (folder `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>/`, options file `<Type>DriverOptions.cs`):
|
||||
|
||||
1. Create new csproj `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts/ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts.csproj`:
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<!-- NO ProjectReference. NO PackageReference. Pure POCO. -->
|
||||
</Project>
|
||||
```
|
||||
2. `git mv` the options file from the runtime project into the contracts project. Preserve namespace (`ZB.MOM.WW.OtOpcUa.Driver.<Type>` → keep the same `namespace ZB.MOM.WW.OtOpcUa.Driver.<Type>` declaration so consumers don't change). If the options file `using`s anything that isn't `System.*` or `System.ComponentModel.DataAnnotations`, strip that dep — most options classes are pure POCO already; if any pulls a runtime-only type, leave a `// TODO: extract <type> too` and capture it in a follow-up task here.
|
||||
3. Add `<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts\ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts.csproj" />` to the runtime project's csproj `<ItemGroup>`.
|
||||
4. Add the new contracts csproj to `ZB.MOM.WW.OtOpcUa.slnx`.
|
||||
5. `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>` → clean. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean.
|
||||
6. Commit. `refactor(driver-<type>): extract <Type>DriverOptions to .Contracts`
|
||||
|
||||
### Task 1.1: Modbus contracts
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1.2 – 1.9 (different folders, different csprojs)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj`
|
||||
- Move: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs` → `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj` (add ProjectReference)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
Follow the Phase 1 pattern above.
|
||||
|
||||
### Task 1.2: AbCip contracts
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1.1, 1.3 – 1.9
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj`
|
||||
- Move: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs` → contracts project
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj`, `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Task 1.3: AbLegacy contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/*`, move `AbLegacyDriverOptions.cs`, update `Driver.AbLegacy.csproj` + slnx.
|
||||
|
||||
### Task 1.4: S7 contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/*`, move `S7DriverOptions.cs`, update `Driver.S7.csproj` + slnx.
|
||||
|
||||
### Task 1.5: TwinCAT contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/*`, move `TwinCATDriverOptions.cs`, update `Driver.TwinCAT.csproj` + slnx.
|
||||
|
||||
### Task 1.6: FOCAS contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/*`, move `FocasDriverOptions.cs`, update `Driver.FOCAS.csproj` + slnx.
|
||||
|
||||
### Task 1.7: OpcUaClient contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/*`, move `OpcUaClientDriverOptions.cs`, update `Driver.OpcUaClient.csproj` + slnx.
|
||||
|
||||
### Task 1.8: Galaxy contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/*`, move `Config/GalaxyDriverOptions.cs` (place at root of contracts project — drop the `Config/` subdir), update `Driver.Galaxy.csproj` + slnx.
|
||||
|
||||
### Task 1.9: Wonderware Historian client contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/*`, move `WonderwareHistorianClientOptions.cs`, update `Driver.Historian.Wonderware.Client.csproj` + slnx.
|
||||
|
||||
### Task 1.10: Validate the full solution + add ProbeTimeout property to each Options class
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (depends on 1.1–1.9)
|
||||
|
||||
**Files:**
|
||||
- Modify: each of the 9 `*DriverOptions.cs` files just moved into the contracts projects.
|
||||
|
||||
**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
|
||||
**Step 2:** Add a `ProbeTimeout` property to each Options class with a driver-appropriate default:
|
||||
|
||||
```csharp
|
||||
/// <summary>Timeout for the AdminUI Test Connect probe. Server-side max = 60s.</summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default {n}s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = <default>;
|
||||
```
|
||||
|
||||
Defaults: Modbus 5, AbCip 5, AbLegacy 5, S7 5, TwinCAT 10, FOCAS 10, OpcUaClient 15, Galaxy 30, Wonderware Historian 15.
|
||||
|
||||
**Step 3:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — clean (any driver runtime code that constructs the Options via positional record syntax may break — fix by using `with { ProbeTimeoutSeconds = N }` or making it a property with default).
|
||||
|
||||
**Step 4:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — all existing tests still pass.
|
||||
|
||||
**Step 5:** Commit. `feat(drivers): expose ProbeTimeoutSeconds on every driver Options class`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Shared section components
|
||||
|
||||
### Task 2.1: DriverFormShell.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2.2, 2.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormShell.razor`
|
||||
|
||||
**Step 1:** Implement panel chrome (`<section class="panel rise">` + `<div class="panel-head">`) with `Title`, `ChildContent`, `Footer` render fragments. Cancel/Save/Delete buttons via parameters: `OnSave` `EventCallback`, `OnCancel` `EventCallback`, `OnDelete` `EventCallback?` (null hides delete). `Busy` bool drives spinner + disabled. Error banner from `Error` string param.
|
||||
|
||||
**Step 2:** Pattern-match the existing `DriverEdit.razor` save bar (lines 116–128) — same visual layout.
|
||||
|
||||
**Step 3:** No code-behind logic; pure presentation.
|
||||
|
||||
**Step 4:** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` — clean.
|
||||
|
||||
**Step 5:** Commit. `feat(adminui): add DriverFormShell shared component`
|
||||
|
||||
### Task 2.2: DriverIdentitySection.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2.1, 2.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor`
|
||||
|
||||
**Step 1:** Component renders the identity fields lifted from `DriverEdit.razor` lines 38–88: `DriverInstanceId` (read-only when not new), `Name`, `NamespaceId` (select from `Namespace[]` passed in), `Enabled`. Bind via `IdentityModel` record passed as `@bind-Value`.
|
||||
|
||||
**Step 2:** Define `IdentityModel` record in the same file's `@code` block: `public sealed record IdentityModel { ... }`. Properties match the existing `FormModel` Identity fields, with their `[Required]` / `[RegularExpression]` attributes preserved.
|
||||
|
||||
**Step 3:** Component takes `IsNew` bool, `Namespaces` list.
|
||||
|
||||
**Step 4:** Build clean.
|
||||
|
||||
**Step 5:** Commit. `feat(adminui): add DriverIdentitySection shared component`
|
||||
|
||||
### Task 2.3: DriverResilienceSection.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 2.1, 2.2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor`
|
||||
|
||||
**Step 1:** For this PR, keep the existing JSON textarea for Polly overrides — typed-form-ifying Polly is out of scope (Section 10 of the design says so implicitly). The component wraps the textarea + help text from `DriverEdit.razor` lines 101–109 in a panel.
|
||||
|
||||
**Step 2:** Bind `[Parameter] public string? ResilienceConfig { get; set; }` + `EventCallback<string?> ResilienceConfigChanged`.
|
||||
|
||||
**Step 3:** Build clean.
|
||||
|
||||
**Step 4:** Commit. `feat(adminui): add DriverResilienceSection shared component`
|
||||
|
||||
### Task 2.4: Wire the three new sections into existing DriverEdit.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (depends on 2.1–2.3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
|
||||
**Step 1:** Replace lines 38–88 with `<DriverIdentitySection @bind-Value="_identityModel" Namespaces="_namespaces" IsNew="IsNew" />`. Wire `_identityModel` to/from `_form` in `OnInitializedAsync` and `SubmitAsync`.
|
||||
|
||||
**Step 2:** Replace lines 101–109 with `<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />`.
|
||||
|
||||
**Step 3:** Wrap the form in `<DriverFormShell Busy="_busy" Error="_error" OnSave="SubmitAsync" OnCancel="@(...)" OnDelete="@(IsNew ? null : DeleteAsync)">`.
|
||||
|
||||
**Step 4:** Smoke test: `dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host` (admin role), open `/clusters/<existing>/drivers/<existing>`, page renders identically to before. (Driver config JSON textarea + identity fields + save bar visually unchanged.)
|
||||
|
||||
**Step 5:** Commit. `refactor(adminui): drive DriverEdit.razor through shared section components`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Router + Type Picker
|
||||
|
||||
### Task 3.1: DriverTypePicker.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3.2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor`
|
||||
|
||||
**Step 1:** `@page "/clusters/{ClusterId}/drivers/new"` (this *replaces* the same route on the existing `DriverEdit.razor` — order matters; we'll yank the old route in Task 3.3).
|
||||
|
||||
**Step 2:** Renders a grid of 9 driver-type cards (`ModbusTcp`, `AbCip`, `AbLegacy`, `S7`, `TwinCat`, `FOCAS`, `OpcUaClient`, `Galaxy`, `Historian.Wonderware`). Each card is a `<a href="/clusters/@ClusterId/drivers/new/<type-slug>">` linking to the typed new-form route. Type slug = lowercase driver-type string (e.g. `modbustcp` → keep human-readable; map slug → DriverType enum-string in a static dictionary in this file).
|
||||
|
||||
**Step 3:** Card content: driver type name, one-line description, an icon (text symbol fine — `[M]`, `[7]`, `[OPC]`, etc., no new images this PR).
|
||||
|
||||
**Step 4:** `<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />` for consistency with peer pages.
|
||||
|
||||
**Step 5:** Build clean. Commit. `feat(adminui): add DriverTypePicker landing page`
|
||||
|
||||
### Task 3.2: DriverEditRouter.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3.1
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor`
|
||||
|
||||
**Step 1:** `@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"` — same edit route as today's `DriverEdit.razor` (will collide; resolve in Task 3.3).
|
||||
|
||||
**Step 2:** In `OnInitializedAsync`: load the `DriverInstance` row, read its `DriverType` string.
|
||||
|
||||
**Step 3:** Map `DriverType` → component type via a static dictionary literal `_componentMap`:
|
||||
```csharp
|
||||
private static readonly IReadOnlyDictionary<string, Type> _componentMap = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase) {
|
||||
["ModbusTcp"] = typeof(ModbusDriverPage),
|
||||
["AbCip"] = typeof(AbCipDriverPage),
|
||||
["AbLegacy"] = typeof(AbLegacyDriverPage),
|
||||
["S7"] = typeof(S7DriverPage),
|
||||
["TwinCat"] = typeof(TwinCatDriverPage),
|
||||
["Focas"] = typeof(FocasDriverPage),
|
||||
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
|
||||
["Galaxy"] = typeof(GalaxyDriverPage),
|
||||
["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage),
|
||||
};
|
||||
```
|
||||
|
||||
**Step 4:** Render `<DynamicComponent Type="_componentMap[_driverType]" Parameters="_params" />` where `_params = new Dictionary<string, object?> { ["ClusterId"] = ClusterId, ["DriverInstanceId"] = DriverInstanceId }`.
|
||||
|
||||
**Step 5:** Until the typed pages exist (Phase 4), the map is empty + this page falls back to a "not yet implemented for type X" notice. Keep route collision deferred until Task 3.3.
|
||||
|
||||
**Step 6:** Build clean. Commit. `feat(adminui): add DriverEditRouter dispatch page`
|
||||
|
||||
### Task 3.3: Resolve route collision — delete old new-route, keep old edit-route until Phase 5
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (depends on 3.1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
|
||||
**Step 1:** Delete line 1 (`@page "/clusters/{ClusterId}/drivers/new"`). Keep line 2 (`@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"`). Until Task 3.4 unhooks it too, the old generic edit page still owns the edit route — `DriverEditRouter.razor` from Task 3.2 stays inert (build fine, but unreachable).
|
||||
|
||||
**Step 2:** Build clean.
|
||||
|
||||
**Step 3:** Smoke test: `/clusters/<id>/drivers/new` now hits `DriverTypePicker.razor`. `/clusters/<id>/drivers/<existing>` still hits `DriverEdit.razor`.
|
||||
|
||||
**Step 4:** Commit. `refactor(adminui): hand /drivers/new to DriverTypePicker`
|
||||
|
||||
### Task 3.4: Hand /drivers/{id} from DriverEdit.razor to DriverEditRouter.razor
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (depends on 3.3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
|
||||
**Step 1:** Delete the remaining `@page` directive — file no longer routes. The router from Task 3.2 owns the route. The DriverEdit `.razor` file stays on disk as a referenceable component used as a fallback inside the router until all 9 typed pages land (Phase 4).
|
||||
|
||||
**Step 2:** Update `DriverEditRouter.razor` `_componentMap` so any driver type *not yet implemented* falls back to `typeof(DriverEdit)` — passing parameters identically. This keeps every existing driver row editable through whichever editor (typed or generic) is available at the time the row is opened.
|
||||
|
||||
**Step 3:** Build clean.
|
||||
|
||||
**Step 4:** Smoke test: open `/clusters/<id>/drivers/<existing-modbus-row>`. Router dispatches → falls back to `DriverEdit.razor` (since `ModbusDriverPage` doesn't exist yet) → page renders as before.
|
||||
|
||||
**Step 5:** Commit. `refactor(adminui): route /drivers/{id} through DriverEditRouter`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Typed driver pages (one per driver)
|
||||
|
||||
**Pattern (apply to every task in this phase):**
|
||||
|
||||
Each `<Type>DriverPage.razor` is a self-contained page with:
|
||||
|
||||
1. Route(s):
|
||||
- `@page "/clusters/{ClusterId}/drivers/new/<type-slug>"` (new path)
|
||||
- The router (Task 3.2) dispatches the edit case — the page itself does NOT declare the edit route; it accepts `[Parameter] public string? DriverInstanceId { get; set; }` and the router passes it.
|
||||
2. Wraps everything in `<DriverFormShell>` (Task 2.1).
|
||||
3. Top: `<DriverIdentitySection>` (Task 2.2).
|
||||
4. Middle: a `<section class="panel">` per logical group of driver options. Each `<InputText>` / `<InputNumber>` / `<InputSelect>` is *explicitly* bound to a property on a form model (`<Type>FormModel`) inside `@code`. Field labels + help text come from the `[Display(Name, Description, GroupName)]` attributes on the `Options` class — but read via `ModelMetadata`, NOT via reflection at render time. (Implementation hint: use a static helper `static string Label<T>(Expression<Func<T,object?>> path)` that pops `[Display]` off at compile-time — simpler is to just hard-code the label in markup and treat `[Display]` as the redundant runtime hint for the API/validator. **Hard-coding labels is the chosen path — keep the page Razor explicit.**)
|
||||
5. Below: `<DriverTestConnectButton DriverType="<Type>" GetConfigJson="@BuildConfigJson" TimeoutSeconds="@_form.ProbeTimeoutSeconds" />` (real component lands in Phase 7; for Phase 4 ship a stub component that just disables the button and shows "Available after Phase 7" — defined in Phase 7 Task 7.5).
|
||||
6. Below: `<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_form.Enabled" />` (only visible in edit mode, not new — stub lands in Phase 6).
|
||||
7. Below: `<DriverResilienceSection>` (Task 2.3).
|
||||
8. Tag picker: opens `<DriverTagPicker>` modal with the driver's picker body (Phase 9).
|
||||
9. Save path: serializes the typed form model to JSON via `JsonSerializer.Serialize(_form.Config, _jsonOpts)`, normalizes (same as today's `NormalizeJson`), upserts the `DriverInstance` row with `RowVersion` opt-concurrency. Match the existing save flow in `DriverEdit.razor:187-257` line-for-line for the upsert mechanics.
|
||||
10. Load path: if `DriverInstanceId != null`, load row, `JsonSerializer.Deserialize<<Type>Options>(row.DriverConfig, _jsonOpts)` where `_jsonOpts = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }`. Wrap into the form model.
|
||||
11. After save, navigate to `/clusters/{ClusterId}/drivers` (the list page — match existing behavior).
|
||||
|
||||
**Per-driver task acceptance:**
|
||||
- Page compiles, routes resolve.
|
||||
- For a new instance: typed form save round-trips to DB; row's `DriverType` is set; `DriverConfig` JSON contains every field shown in the form.
|
||||
- For an edit: page loads existing row; every field populates; save preserves all fields.
|
||||
- Update `DriverEditRouter.razor` `_componentMap` to point this driver type at the new page.
|
||||
- Update `ClusterDrivers.razor` (the list page) — no change needed; it already links via the unified edit route.
|
||||
- Add a unit test in `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/<Type>DriverPageFormSerializationTests.cs` round-tripping `<Type>Options` ↔ JSON ↔ form-model ↔ `<Type>Options`. Use Shouldly.
|
||||
|
||||
### Task 4.1: ModbusDriverPage.razor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 4.2 – 4.9
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor`
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor` (`_componentMap`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (add `ProjectReference` to `Driver.Modbus.Contracts`)
|
||||
|
||||
Follow Phase 4 pattern. Modbus is the largest options class (289 lines); group into panels: **Transport** (endpoint, port, unit ID), **Polling** (interval, batch sizes), **Probe** (probe options + `ProbeTimeoutSeconds`), **Tuning** (timeouts, retries). Read `ModbusDriverOptions.cs` first to enumerate every property.
|
||||
|
||||
### Task 4.2: AbCipDriverPage.razor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 4.1, 4.3 – 4.9
|
||||
|
||||
**Files:** as 4.1, swap `Modbus` → `AbCip`. Add `ProjectReference` to `Driver.AbCip.Contracts`. Read `AbCipDriverOptions.cs` to enumerate fields. PLC family selector (CompactLogix / ControlLogix) is a key field.
|
||||
|
||||
### Task 4.3: AbLegacyDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1, 4.2, 4.4–4.9
|
||||
**Files:** as 4.1 for AbLegacy. Read `AbLegacyDriverOptions.cs`.
|
||||
|
||||
### Task 4.4: S7DriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.3, 4.5–4.9
|
||||
**Files:** as 4.1 for S7. CPU/rack/slot tuple in a Connection panel.
|
||||
|
||||
### Task 4.5: TwinCatDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.4, 4.6–4.9
|
||||
**Files:** as 4.1 for TwinCAT. AMS NetId + port in a Connection panel.
|
||||
|
||||
### Task 4.6: FocasDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.5, 4.7–4.9
|
||||
**Files:** as 4.1 for FOCAS. CNC series + connection params.
|
||||
|
||||
### Task 4.7: OpcUaClientDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.6, 4.8, 4.9
|
||||
**Files:** as 4.1 for OpcUaClient. **Security profile (None / Basic256Sha256-Sign / Basic256Sha256-SignAndEncrypt)** is a dropdown sourced from the same enum the OPC UA Server uses (cross-ref `docs/security.md`). Username/password are `[DataType(DataType.Password)]`.
|
||||
|
||||
### Task 4.8: GalaxyDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.7, 4.9
|
||||
**Files:** as 4.1 for Galaxy. Two panels: **mxaccessgw** (gateway endpoint, API key — password input), **Galaxy** (ClientName, SQL config db connection if exposed in options). API key field is `[DataType(DataType.Password)]`.
|
||||
|
||||
### Task 4.9: HistorianWonderwareDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.8
|
||||
**Files:** as 4.1 for the Wonderware Historian (the page covers the *driver*'s view of historian client options — the `WonderwareHistorianClientOptions` lives in the `.Client.Contracts` project from Task 1.9). The driver type-string in DriverInstance is `Historian.Wonderware`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Delete the generic DriverEdit.razor
|
||||
|
||||
### Task 5.1: Remove DriverEdit.razor + fallback in router
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (depends on all 4.x tasks)
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor` (remove fallback)
|
||||
|
||||
**Step 1:** Confirm `_componentMap` in the router has all 9 driver types. Delete the "fallback to DriverEdit" branch.
|
||||
|
||||
**Step 2:** `git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`.
|
||||
|
||||
**Step 3:** Build clean. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
|
||||
**Step 4:** Smoke test all 9 driver types: open the list page, open one existing row of each type, verify the typed page renders. (For types without existing rows on dev DB, create one via the type picker first.)
|
||||
|
||||
**Step 5:** Commit. `refactor(adminui): retire generic DriverEdit.razon (typed pages cover all 9 drivers)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Live status panel
|
||||
|
||||
### Task 6.1: DriverHealthChanged DPS message
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 6.2 (different folders)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs`
|
||||
|
||||
**Step 1:** Define record `public sealed record DriverHealthChanged(string ClusterId, string DriverInstanceId, string State, DateTime? LastSuccessUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc);` — `State` is the `DriverState` enum string (matches `Healthy` / `Connecting` / `Faulted` / `Unknown` used by `DriverHealth`).
|
||||
|
||||
**Step 2:** Add `[MemoryPackable]` if peer messages in this folder use MemoryPack — match the folder's existing pattern. (Look at `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs` for the canonical pattern.)
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(messages): add DriverHealthChanged DPS contract`
|
||||
|
||||
### Task 6.2: Publish DriverHealthChanged from each driver actor
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min (per driver; bundle = ~15 min — **split this into 6.2a–6.2d if implementer balks**)
|
||||
**Parallelizable with:** none (touches every driver actor)
|
||||
|
||||
**Files:**
|
||||
- Modify: each `<Type>Driver.cs` — find the place that updates `_health` (e.g. `FocasDriver.cs:565+`, `ModbusDriver.cs:1180+`). After every `Volatile.Write(ref _health, ...)`, also publish to DPS topic `driver-health-{ClusterId}` via the injected `DistributedPubSub.Get(_actorSystem).Mediator`.
|
||||
- Find pattern: `Volatile.Write(ref _health,` — every occurrence in `src/Drivers/**/*.cs` not under obj/bin.
|
||||
|
||||
**Step 1:** Inject the publish callback into each driver. The cleanest hook is `IDriverHealthPublisher` (new interface in `Core.Abstractions`), with the Akka-backed impl living in `Runtime` and DI-registered there. Driver constructors take `IDriverHealthPublisher` (nullable for backward compat in tests).
|
||||
|
||||
**Step 2:** After each `_health` write, call `_healthPublisher?.Publish(new DriverHealthChanged(...))`. Pull `ClusterId` + `DriverInstanceId` from the driver's existing identity (every driver already knows its instance ID for telemetry tags).
|
||||
|
||||
**Step 3:** Add `ErrorCount5Min` tracking: a sliding-window counter on the driver — bump on every transition into `Faulted`, decay over 5min. Simple impl: a `Queue<DateTime>` guarded by lock; on read, dequeue entries older than 5min and return `.Count`.
|
||||
|
||||
**Step 4:** `dotnet build` clean. `dotnet test` clean. Driver unit tests may need a no-op `IDriverHealthPublisher` (provide one in Core.Abstractions: `public sealed class NullDriverHealthPublisher : IDriverHealthPublisher { public void Publish(DriverHealthChanged _) { } }`).
|
||||
|
||||
**Step 5:** Commit. `feat(drivers): publish DriverHealthChanged to DPS on every health transition`
|
||||
|
||||
### Task 6.3: DriverStatusHub
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 6.4
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusHub.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs` (register hub)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs` (`MapHub<DriverStatusHub>("/hubs/driverstatus")`)
|
||||
|
||||
**Step 1:** Hub with one method: `Task JoinDriver(string driverInstanceId)` — adds the connection to a group named `driver:{driverInstanceId}`, immediately invokes `Clients.Caller.SendAsync("status", currentSnapshot)`. Inject `IDriverStatusSnapshotStore` (new — Task 6.4) to read the current snapshot.
|
||||
|
||||
**Step 2:** Hub method-name constant: `public const string MethodName = "status";`.
|
||||
|
||||
**Step 3:** `[Microsoft.AspNetCore.Authorization.Authorize]` on the class (same auth as the existing AdminUI hubs).
|
||||
|
||||
**Step 4:** Build clean. Commit. `feat(adminui): add DriverStatusHub`
|
||||
|
||||
### Task 6.4: DriverStatusSignalRBridge + snapshot store
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 6.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusSignalRBridge.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs` (spawn bridge actor on admin-role startup; DI-register snapshot store as singleton)
|
||||
|
||||
**Step 1:** Bridge = Akka `ReceiveActor`. `PreStart` subscribes to DPS topic `driver-health-*` (or one topic + per-cluster filter — pick the same wildcard convention `FleetStatusSignalRBridge` uses). On `DriverHealthChanged msg`: writes to `IDriverStatusSnapshotStore` (latest-snapshot-wins, keyed by instance ID), then `_hub.Clients.Group($"driver:{msg.DriverInstanceId}").SendAsync(DriverStatusHub.MethodName, msg)`.
|
||||
|
||||
**Step 2:** `InMemoryDriverStatusSnapshotStore`: `ConcurrentDictionary<string, DriverHealthChanged> _byInstance;` — `Upsert(msg)` and `TryGet(instanceId, out msg)`.
|
||||
|
||||
**Step 3:** Wire bridge in `AddOtOpcUaSignalRBridges` (or equivalent). Singleton snapshot store. Build clean.
|
||||
|
||||
**Step 4:** Commit. `feat(adminui): add DriverStatusSignalRBridge + snapshot store`
|
||||
|
||||
### Task 6.5: DriverStatusPanel.razor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (consumes 6.3 + 6.4)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor`
|
||||
|
||||
**Step 1:** Parameters: `DriverInstanceId`, `Enabled`. Inject `NavigationManager` for hub URL building, `IServiceProvider` for hub-connection auth.
|
||||
|
||||
**Step 2:** `OnInitializedAsync`: if `Enabled == false`, render "Disabled — not deployed" + skip the hub. Else build a `HubConnection` (`HubConnectionBuilder` → `WithUrl(Nav.ToAbsoluteUri("/hubs/driverstatus"))` → `WithAutomaticReconnect()` → `Build()`), register `On<DriverHealthChanged>("status", ...)`, `StartAsync`, then `InvokeAsync("JoinDriver", DriverInstanceId)`. The handler updates `_snapshot` + `StateHasChanged`.
|
||||
|
||||
**Step 3:** Render: state chip (color-mapped: `Healthy` green, `Connecting` yellow, `Faulted` red, `Unknown` gray) + "last success {humanized timestamp}" + `ErrorCount5Min` badge + collapsible "last error" panel showing `LastError` if set. Visual reuses existing `chip` / `panel` styles from sibling pages.
|
||||
|
||||
**Step 4:** `_lastSnapshotAt = DateTime.UtcNow` on each push; if `(now - _lastSnapshotAt) > 30s` (timer-driven re-render every 5s), add `dim` class to the whole panel.
|
||||
|
||||
**Step 5:** `DisposeAsync`: `await _hub.DisposeAsync()`. Implement `IAsyncDisposable`.
|
||||
|
||||
**Step 6:** Wire into all 9 driver pages. In edit mode (i.e. `DriverInstanceId != null`), render `<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_form.Identity.Enabled" />` above the resilience section.
|
||||
|
||||
**Step 7:** Build clean. Smoke test: bring up `lmxopcua-fix up modbus`, deploy a Modbus driver pointing at the sim, observe `Healthy` push within seconds. Stop the sim, observe `Faulted` push within the driver's poll interval. Commit. `feat(adminui): live driver status panel on every driver page`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Test Connect
|
||||
|
||||
### Task 7.1: IDriverProbe interface + TestDriverConnect message
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 7.2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs`
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnect.cs`
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnectResult.cs`
|
||||
|
||||
**Step 1:** `IDriverProbe`:
|
||||
```csharp
|
||||
public interface IDriverProbe
|
||||
{
|
||||
/// <summary>Driver-type string this probe handles (matches DriverInstance.DriverType).</summary>
|
||||
string DriverType { get; }
|
||||
/// <summary>Run a connection probe. Never mutates; never writes.</summary>
|
||||
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
|
||||
}
|
||||
public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency);
|
||||
```
|
||||
|
||||
**Step 2:** `TestDriverConnect(string DriverType, string ConfigJson, int TimeoutSeconds, Guid CorrelationId)` and `TestDriverConnectResult(bool Ok, string? Message, double? LatencyMs, Guid CorrelationId)`. Match the MemoryPack conventions of peer messages in `Messages/Admin/`.
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(messages,abstractions): add IDriverProbe + TestDriverConnect contract`
|
||||
|
||||
### Task 7.2: AdminOperationsActor handler for TestDriverConnect
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7.1 (separate file; modify late)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs`
|
||||
|
||||
**Step 1:** Add ctor param `IEnumerable<IDriverProbe> probes`. Build `_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase)` in ctor. Update `Props` factory accordingly.
|
||||
|
||||
**Step 2:** `ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync)`. Handler:
|
||||
```csharp
|
||||
private async Task HandleTestDriverConnectAsync(TestDriverConnect msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
if (!_probesByType.TryGetValue(msg.DriverType, out var probe))
|
||||
{
|
||||
replyTo.Tell(new TestDriverConnectResult(false, $"No probe registered for driver type '{msg.DriverType}'", null, msg.CorrelationId));
|
||||
return;
|
||||
}
|
||||
var clampedSec = Math.Clamp(msg.TimeoutSeconds, 1, 60);
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(clampedSec));
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = await probe.ProbeAsync(msg.ConfigJson, TimeSpan.FromSeconds(clampedSec), cts.Token);
|
||||
replyTo.Tell(new TestDriverConnectResult(result.Ok, result.Message, sw.Elapsed.TotalMilliseconds, msg.CorrelationId));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
replyTo.Tell(new TestDriverConnectResult(false, $"Probe timed out after {clampedSec}s", null, msg.CorrelationId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "Probe for {DriverType} threw", msg.DriverType);
|
||||
replyTo.Tell(new TestDriverConnectResult(false, ex.Message, null, msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3:** Update wherever `AdminOperationsActor.Props(...)` is called (search the repo) to pass the new `probes` enumerable. Likely in `Runtime` DI registration — register all `IDriverProbe` impls then resolve `IEnumerable<IDriverProbe>` for the singleton.
|
||||
|
||||
**Step 4:** Build clean. Commit. `feat(adminops): handle TestDriverConnect via per-driver IDriverProbe`
|
||||
|
||||
### Task 7.3: TCP-only probe impls (Modbus, AbCip, AbLegacy, S7)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7.4
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs`
|
||||
|
||||
**Step 1:** Each impl deserializes its own Options class from the configJson, extracts `Endpoint` (host:port — exact field name depends on the Options class), opens a TCP `Socket` with `ConnectAsync(host, port, ct)`, closes immediately. On success: `(true, null, sw.Elapsed)`. On `SocketException`: `(false, ex.SocketErrorCode.ToString(), null)`.
|
||||
|
||||
**Step 2:** Register each as `services.AddSingleton<IDriverProbe, ModbusDriverProbe>()` (and peers) in the driver's existing `*FactoryExtensions.cs` `Add*` method.
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(drivers): TCP-only probes for Modbus, AbCip, AbLegacy, S7`
|
||||
|
||||
### Task 7.4: Specialty probes (FOCAS, TwinCAT, OpcUaClient, Galaxy, Historian.Wonderware)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (per driver; bundle ~15 min — **may need to split into 7.4a–7.4e**)
|
||||
**Parallelizable with:** Task 7.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs` — calls FOCAS `cnc_allclibhndl3` connect + immediate `cnc_freelibhndl`. Reuses the existing `IFocasClient.Connect`.
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs` — opens an `AmsAddress` and sends an ADS Read State request.
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs` — opens an `OPCFoundation.NetStandard.Opc.Ua.Client.Session` against the configured endpoint with the configured security profile, immediately closes.
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Health/GalaxyDriverProbe.cs` — sends `MxCommand.Ping` to mxaccessgw via the existing gRPC client. (Build on the existing `Health/` folder.)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/HistorianWonderwareDriverProbe.cs` — TCP probe to historian endpoint (historian client uses an MX-style transport; cheap path is a TCP connect to the historian's IPC port + close).
|
||||
|
||||
**Step 1:** Each impl registers in its driver's `Add*` extension.
|
||||
|
||||
**Step 2:** Build clean. `dotnet test` clean. Commit. `feat(drivers): specialty Test Connect probes for FOCAS/TwinCAT/OPCUA/Galaxy/Historian`
|
||||
|
||||
### Task 7.5: DriverTestConnectButton.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (consumes 7.2 + 7.3 + 7.4)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs`
|
||||
|
||||
**Step 1:** `AdminProbeService` — thin wrapper around `IAdminOperationsClient`. Method `Task<TestDriverConnectResult> TestAsync(string driverType, string configJson, int timeoutSeconds, CancellationToken ct)`. Builds the message + Asks + applies a `Task.WhenAny` 65s timeout wall as outer guard. DI-register as scoped.
|
||||
|
||||
**Step 2:** Button component params: `DriverType`, `GetConfigJson` (`Func<string>`), `TimeoutSeconds` (`int`). Renders `<button class="btn btn-outline-primary btn-sm">Test Connect</button>` + inline result chip (green tick + latency, or red x + message). Spinner during in-flight. Auto-clears chip after 30s.
|
||||
|
||||
**Step 3:** On click: invokes `AdminProbeService.TestAsync(DriverType, GetConfigJson(), TimeoutSeconds, ct)` with `CancellationToken.None` (the actor-side timeout already bounds it).
|
||||
|
||||
**Step 4:** Wire into all 9 driver pages by replacing the Phase 4 stub.
|
||||
|
||||
**Step 5:** Build clean. Smoke test: open `/clusters/<id>/drivers/new/modbustcp`, type sim endpoint into form, click Test Connect → green. Wrong port → red within 5s. Black-holed IP → "Probe timed out after 5s".
|
||||
|
||||
**Step 6:** Commit. `feat(adminui): Test Connect button on every driver page`
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Reconnect / Restart
|
||||
|
||||
### Task 8.1: RestartDriver + ReconnectDriver messages + AdminOperationsActor handlers
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8.2 (separate files)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs`
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/ReconnectDriver.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs`
|
||||
|
||||
**Step 1:** Messages: `RestartDriver(string ClusterId, string DriverInstanceId, string ActorByUserName, Guid CorrelationId)` + `RestartDriverResult(bool Ok, string? Message, Guid CorrelationId)`. Same shape for `Reconnect*`.
|
||||
|
||||
**Step 2:** Handlers in the actor. They locate the running driver actor (the existing `DriverHostActor` hierarchy already addresses driver actors by instance ID — find the existing lookup mechanism in `DriverHostActor.cs` / `Runtime` and reuse it). Reconnect = Tell the driver actor a `Reconnect` internal command; Restart = Tell its supervisor to stop+restart the child.
|
||||
|
||||
**Step 3:** Audit-log every call via the existing `ConfigEdits` mechanism — entity type `DriverInstance`, fields `{op: restart|reconnect}`.
|
||||
|
||||
**Step 4:** Build clean. Commit. `feat(adminops): Restart/Reconnect driver operations`
|
||||
|
||||
### Task 8.2: DriverOperator authorization policy
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 8.1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/` — add a `DriverOperator` policy alongside the existing `WriteOperate` / `WriteTune` policies. Map to LDAP group `ot-driver-operator` (or document the chosen group name in `docs/Security.md`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/` — register the policy with `AddAuthorizationBuilder().AddPolicy("DriverOperator", p => p.RequireRole("ot-driver-operator"))` (or whatever pattern the existing AdminUI policies use).
|
||||
- Modify: `docs/Security.md` — add a row to the role/policy table.
|
||||
|
||||
**Step 1:** Mirror the shape of the most-similar existing policy (probably `WriteOperate`).
|
||||
|
||||
**Step 2:** Build clean. Commit. `feat(security): add DriverOperator authorization policy`
|
||||
|
||||
### Task 8.3: Wire Reconnect/Restart buttons into DriverStatusPanel
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (depends on 8.1 + 8.2 + 6.5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor`
|
||||
|
||||
**Step 1:** Inject `IAuthorizationService`. In `OnInitializedAsync`, check `AuthorizeAsync(user, null, "DriverOperator")` → set `_canOperate` bool. Render buttons only when `_canOperate && Enabled`.
|
||||
|
||||
**Step 2:** Two buttons:
|
||||
- **Reconnect** — no confirm. Click: spinner on button, set inline "Reconnecting…" chip, invoke `AdminOperationsClient.AskAsync<ReconnectDriverResult>(new ReconnectDriver(...))`. Result chip clears once next `DriverHealthChanged` push arrives.
|
||||
- **Restart** — confirm dialog "Restart driver `<id>`? This briefly interrupts subscriptions." Same flow otherwise.
|
||||
|
||||
**Step 3:** Both buttons disabled (greyed-out) during in-flight ops or during a Test Connect on the same page (publish a simple page-scoped `bool _busyAnything` via the parent driver page → flows to the panel via a parameter).
|
||||
|
||||
**Step 4:** Build clean. Smoke test: Reconnect → see `Connecting → Healthy` transition push in the panel. Restart → confirm → see actor restart. Commit. `feat(adminui): Reconnect/Restart on DriverStatusPanel (DriverOperator-gated)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Static address pickers
|
||||
|
||||
### Task 9.1: DriverTagPicker.razor modal shell
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** 9.2–9.10
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTagPicker.razor`
|
||||
|
||||
**Step 1:** Modal shell. Params: `Visible` (`bool`), `OnClose` (`EventCallback`), `Title` (string, e.g. "Modbus address"), `ChildContent` (`RenderFragment`), `OnPickAddress` (`EventCallback<string>`). Renders a Bootstrap-style `.modal.show` (no JS interop — Razor-managed visibility class). The child fragment is the per-driver picker body.
|
||||
|
||||
**Step 2:** Includes a search box + "Use this address" button at the bottom; "Use" calls `OnPickAddress` with the value currently bound in the child.
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(adminui): DriverTagPicker modal shell`
|
||||
|
||||
### Task 9.2 – 9.10: Per-driver static picker bodies (9 tasks)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min each
|
||||
**Parallelizable with:** each other (different files)
|
||||
|
||||
For each driver, create `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/<Type>AddressPickerBody.razor`. Per Section 6.2 of the design:
|
||||
|
||||
| Task | Driver | Picker body |
|
||||
|---|---|---|
|
||||
| 9.2 | Modbus | Register-type dropdown (Coil/DiscreteInput/Holding/Input) + offset spinner + length → renders `4x00001-4`. |
|
||||
| 9.3 | AbCip | Tag name + element index; CompactLogix/ControlLogix hint from form. |
|
||||
| 9.4 | AbLegacy | File type (N/B/F/I/O/S/T/C/R) + file number + element. |
|
||||
| 9.5 | S7 | Area (DB/M/I/Q) + db-number + offset + S7 type → `DB10.DBD20:REAL`. |
|
||||
| 9.6 | TwinCat | Free-text ADS variable name + format hint. |
|
||||
| 9.7 | FOCAS | Parameter group dropdown + parameter ID; drives the FOCAS function-code lookup table. |
|
||||
| 9.8 | OpcUaClient | Free-text NodeId field. (Live browse deferred — Section 10.) |
|
||||
| 9.9 | Galaxy | Free-text `tag_name.AttributeName` field. (Live browse deferred.) |
|
||||
| 9.10 | Historian.Wonderware | Tag name + retrieval mode + interval. |
|
||||
|
||||
Each task:
|
||||
|
||||
**Step 1:** Create the per-driver picker body component.
|
||||
|
||||
**Step 2:** Add a small unit test in `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/<Type>AddressBuilderTests.cs` — for the static address builders that compute a string (Modbus, S7, FOCAS), assert input → string output. For free-text bodies (OpcUaClient, Galaxy), test pass-through.
|
||||
|
||||
**Step 3:** Wire into the matching `*DriverPage.razor` — add a "Pick address" button that toggles `<DriverTagPicker>` open with this body as its child.
|
||||
|
||||
**Step 4:** Build clean. Commit per task. `feat(adminui): <Type> address picker`
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — End-to-end verification
|
||||
|
||||
### Task 10.1: DriverTestConnectE2eTests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 10.2, 10.3
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverTestConnectE2eTests.cs`
|
||||
|
||||
**Step 1:** Use the existing Docker fixture pattern from peer tests in this project. Three test methods: Modbus, AbCip, S7 — each starts the corresponding `lmxopcua-fix up <driver>` fixture (the test project's fixture base class handles it) + asserts green probe vs sim, red probe vs wrong port, timeout vs `1.2.3.4:502` (black-holed).
|
||||
|
||||
**Step 2:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter DriverTestConnectE2eTests` — passes.
|
||||
|
||||
**Step 3:** Commit. `test(adminui): E2E Test Connect probes against Docker sims`
|
||||
|
||||
### Task 10.2: DriverReconnectE2eTests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 10.1, 10.3
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverReconnectE2eTests.cs`
|
||||
|
||||
**Step 1:** Start a Modbus driver against the sim, observe `Healthy`, dispatch `ReconnectDriver` via the in-cluster admin ops client, assert `Connecting → Healthy` transitions within 5s.
|
||||
|
||||
**Step 2:** Build + run. Commit. `test(adminui): E2E Reconnect operation`
|
||||
|
||||
### Task 10.3: DriverStatusHubE2eTests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 10.1, 10.2
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverStatusHubE2eTests.cs`
|
||||
|
||||
**Step 1:** Open a SignalR connection to `/hubs/driverstatus`, invoke `JoinDriver`, force a `DriverHealthChanged` via test seam (publish directly to the DPS topic), assert push received within 1s.
|
||||
|
||||
**Step 2:** Build + run. Commit. `test(adminui): E2E DriverStatusHub push`
|
||||
|
||||
### Task 10.4: Manual smoke checklist (documented, not automated)
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-05-28-adminui-driver-pages-design.md` — replace Section 8.3 stub with the actual checklist as run, with timestamps.
|
||||
|
||||
**Step 1:** Run the checklist (Section 8.3 of the design). Tick each item.
|
||||
|
||||
**Step 2:** Commit. `docs(plans): record AdminUI driver pages smoke-test results`
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope (documented follow-ups)
|
||||
|
||||
These are NOT part of this plan. Capture as separate work items after merge:
|
||||
|
||||
- Live OPC UA browse in OpcUaClient picker.
|
||||
- Live Galaxy hierarchy browse in Galaxy picker.
|
||||
- Historian.Wonderware tag list pulled from the historian store.
|
||||
- DriverStatusPanel history graphs + per-tag diagnostics.
|
||||
- Per-driver bespoke controls beyond Reconnect/Restart.
|
||||
- Polly resilience config typed-form (still a JSON textarea this PR).
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting verification (run before final PR)
|
||||
|
||||
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
3. `lmxopcua-fix up modbus`, then run the manual smoke (10.4).
|
||||
4. Review `git diff --stat master..` — confirm scope matches plan (no surprise file changes).
|
||||
5. Confirm `OtOpcUa-docs-issues.md` shows no new XML-doc warnings introduced by the new code (run `commentchecker-aot` on the AdminUI + Drivers/* trees).
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-28-adminui-driver-pages-plan.md",
|
||||
"designPath": "docs/plans/2026-05-28-adminui-driver-pages-design.md",
|
||||
"tasks": [
|
||||
{"id": "0.1", "subject": "Create AdminUI test project + slnx entry + placeholder test", "status": "completed", "commit": "dc12c37"},
|
||||
|
||||
{"id": "1.1", "subject": "Driver.Modbus.Contracts — extract ModbusDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5058a56", "notes": "Has 1 ProjectReference to Modbus.Addressing (sibling zero-dep enum project) — design intent preserved."},
|
||||
{"id": "1.2", "subject": "Driver.AbCip.Contracts — extract AbCipDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "b474d63", "notes": "AbCipDataType enum moved with Options; extensions split into runtime."},
|
||||
{"id": "1.3", "subject": "Driver.AbLegacy.Contracts — extract AbLegacyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "4902295", "notes": "AbLegacyDataType + AbLegacyPlcFamilyProfile also moved; extensions split."},
|
||||
{"id": "1.4", "subject": "Driver.S7.Contracts — extract S7DriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "9f62f2c", "notes": "Parallel S7CpuType enum (7 values) + S7CpuTypeMap in runtime; S7.Cli + 2 tests fixed for type change."},
|
||||
{"id": "1.5", "subject": "Driver.TwinCAT.Contracts — extract TwinCATDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "a88721c", "notes": "TwinCATDataType enum moved; extensions split."},
|
||||
{"id": "1.6", "subject": "Driver.FOCAS.Contracts — extract FocasDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "d892ab9", "notes": "FocasCncSeries + FocasDataType enums moved; extensions split."},
|
||||
{"id": "1.7", "subject": "Driver.OpcUaClient.Contracts — extract OpcUaClientDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5f0e048", "notes": "All 4 enums self-contained in options file; no NuGet types leaked."},
|
||||
{"id": "1.8", "subject": "Driver.Galaxy.Contracts — extract GalaxyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5ffbc42", "notes": "Moved from Config/ subdir to contracts root; namespace preserved."},
|
||||
{"id": "1.9", "subject": "Driver.Historian.Wonderware.Client.Contracts — extract options", "status": "completed", "blockedBy": ["0.1"], "commit": "8c0a320", "notes": "Pure record, primitives only."},
|
||||
{"id": "1.10", "subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation", "status": "completed", "blockedBy": ["1.1","1.2","1.3","1.4","1.5","1.6","1.7","1.8","1.9"], "commit": "f2f6eeb"},
|
||||
|
||||
{"id": "2.1", "subject": "DriverFormShell.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "85af126"},
|
||||
{"id": "2.2", "subject": "DriverIdentitySection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "1ff3875", "notes": "Bonus ValidationMessage tags added."},
|
||||
{"id": "2.3", "subject": "DriverResilienceSection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "a008530"},
|
||||
{"id": "2.4", "subject": "Wire shared sections into existing DriverEdit.razor", "status": "completed", "blockedBy": ["2.1","2.2","2.3"], "commit": "a28f4cd", "notes": "Net -74 lines; zero functional regression."},
|
||||
|
||||
{"id": "3.1", "subject": "DriverTypePicker.razor (route: /drivers/new)", "status": "completed", "blockedBy": ["2.4"], "commit": "c0ce5d0"},
|
||||
{"id": "3.2", "subject": "DriverEditRouter.razor with DynamicComponent dispatch","status": "completed", "blockedBy": ["2.4"], "commit": "55e8bf7"},
|
||||
{"id": "3.3", "subject": "Hand /drivers/new from DriverEdit to DriverTypePicker","status": "completed", "blockedBy": ["3.1"], "commit": "27b3a01", "notes": "Bundled with 3.4 — single commit removed both @page directives."},
|
||||
{"id": "3.4", "subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)", "status": "completed", "blockedBy": ["3.2","3.3"], "commit": "27b3a01"},
|
||||
|
||||
{"id": "4.0", "subject": "AdminUI csproj references all 9 Driver.*.Contracts", "status": "completed", "blockedBy": ["1.10","3.4"], "commit": "7014c93", "notes": "Inserted as a precondition for parallel 4.1-4.9 implementation."},
|
||||
{"id": "4.1", "subject": "ModbusDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a3073d1"},
|
||||
{"id": "4.2", "subject": "AbCipDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dc21cba"},
|
||||
{"id": "4.3", "subject": "AbLegacyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "059a621"},
|
||||
{"id": "4.4", "subject": "S7DriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "5cad9b2"},
|
||||
{"id": "4.5", "subject": "TwinCatDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dfbf679"},
|
||||
{"id": "4.6", "subject": "FocasDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "8149739"},
|
||||
{"id": "4.7", "subject": "OpcUaClientDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "efcc231"},
|
||||
{"id": "4.8", "subject": "GalaxyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a243cfd"},
|
||||
{"id": "4.9", "subject": "HistorianWonderwareDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "2c16062"},
|
||||
{"id": "4.10","subject": "Wire all 9 typed pages into DriverEditRouter._componentMap", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "5f8fa70"},
|
||||
{"id": "4.11","subject": "Fixup: S7 Tags data-loss + missing FormModel tests (post-review)", "status": "completed", "blockedBy": ["4.10"], "commit": "c4086c2"},
|
||||
|
||||
{"id": "5.1", "subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "a971db3"},
|
||||
|
||||
{"id": "6.1", "subject": "DriverHealthChanged DPS message contract", "status": "pending", "blockedBy": ["5.1"]},
|
||||
{"id": "6.2", "subject": "Publish DriverHealthChanged from each driver actor (IDriverHealthPublisher)", "status": "pending", "blockedBy": ["6.1"]},
|
||||
{"id": "6.3", "subject": "DriverStatusHub", "status": "pending", "blockedBy": ["6.1"]},
|
||||
{"id": "6.4", "subject": "DriverStatusSignalRBridge + InMemoryDriverStatusSnapshotStore", "status": "pending", "blockedBy": ["6.2","6.3"]},
|
||||
{"id": "6.5", "subject": "DriverStatusPanel.razor + wire into all 9 driver pages", "status": "pending", "blockedBy": ["6.4"]},
|
||||
|
||||
{"id": "7.1", "subject": "IDriverProbe interface + TestDriverConnect messages", "status": "pending", "blockedBy": ["5.1"]},
|
||||
{"id": "7.2", "subject": "AdminOperationsActor handler for TestDriverConnect", "status": "pending", "blockedBy": ["7.1"]},
|
||||
{"id": "7.3", "subject": "TCP probes (Modbus, AbCip, AbLegacy, S7)", "status": "pending", "blockedBy": ["7.1"]},
|
||||
{"id": "7.4", "subject": "Specialty probes (FOCAS, TwinCAT, OPCUA, Galaxy, Historian)", "status": "pending", "blockedBy": ["7.1"]},
|
||||
{"id": "7.5", "subject": "AdminProbeService + DriverTestConnectButton.razor + wire into pages", "status": "pending", "blockedBy": ["7.2","7.3","7.4"]},
|
||||
|
||||
{"id": "8.1", "subject": "RestartDriver + ReconnectDriver messages + AdminOperationsActor handlers", "status": "pending", "blockedBy": ["6.5","7.5"]},
|
||||
{"id": "8.2", "subject": "DriverOperator authorization policy + docs/Security.md update", "status": "pending", "blockedBy": ["6.5"]},
|
||||
{"id": "8.3", "subject": "Wire Reconnect/Restart buttons into DriverStatusPanel", "status": "pending", "blockedBy": ["8.1","8.2"]},
|
||||
|
||||
{"id": "9.1", "subject": "DriverTagPicker.razor modal shell", "status": "pending", "blockedBy": ["5.1"]},
|
||||
{"id": "9.2", "subject": "Modbus address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.3", "subject": "AbCip address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.4", "subject": "AbLegacy address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.5", "subject": "S7 address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.6", "subject": "TwinCat address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.7", "subject": "FOCAS address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.8", "subject": "OpcUaClient picker body (free-text NodeId)", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.9", "subject": "Galaxy picker body (free-text tag_name.AttributeName)", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.10","subject": "Historian.Wonderware picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
|
||||
{"id": "10.1", "subject": "DriverTestConnectE2eTests (Modbus/AbCip/S7 vs Docker sims)", "status": "pending", "blockedBy": ["8.3","9.10"]},
|
||||
{"id": "10.2", "subject": "DriverReconnectE2eTests", "status": "pending", "blockedBy": ["8.3","9.10"]},
|
||||
{"id": "10.3", "subject": "DriverStatusHubE2eTests", "status": "pending", "blockedBy": ["8.3","9.10"]},
|
||||
{"id": "10.4", "subject": "Manual smoke checklist (documented)", "status": "pending", "blockedBy": ["10.1","10.2","10.3"]}
|
||||
],
|
||||
"lastUpdated": "2026-05-28"
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
# Live address browsers for OpcUaClient + Galaxy drivers — design
|
||||
|
||||
> **Status:** approved 2026-05-28. Implementation plan to follow via `writing-plans`.
|
||||
> **Builds on:** PR that shipped driver-specific AdminUI pages (commit `0d3ec46`).
|
||||
> Both `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` were
|
||||
> intentionally shipped as static stubs ("enter the string manually") with live
|
||||
> browse deferred to this follow-up.
|
||||
|
||||
**Goal:** Add lazy, ad-hoc browse trees to the OpcUaClient and Galaxy address pickers in the AdminUI, so operators can navigate the remote server's (or galaxy's) hierarchy and pick an address rather than typing it.
|
||||
|
||||
**Architecture:** A new `IDriverBrowser` abstraction registered per driver type (parallel to the runtime's `IDriverProbe`), with implementations housed in sibling `*.Browser` projects under `src/Drivers/`. AdminUI owns the live browse sessions in-process via a `BrowseSessionRegistry` singleton with a 2-minute idle TTL and an `IHostedService` reaper. Razor picker bodies talk to a scoped `IBrowserSessionService`; no actor messages on the hot path.
|
||||
|
||||
**Tech stack:** .NET 10 / Blazor Server / OPCFoundation.NetStandard.Opc.Ua.Client / `ZB.MOM.WW.MxGateway.Client` (sibling repo, lazy-browse API already shipped).
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
### Abstraction
|
||||
|
||||
```csharp
|
||||
// Commons (shared)
|
||||
public interface IDriverBrowser {
|
||||
string DriverType { get; } // "OpcUaClient", "Galaxy", ...
|
||||
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IBrowseSession : IAsyncDisposable {
|
||||
Guid Token { get; }
|
||||
DateTime LastUsedUtc { get; }
|
||||
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct);
|
||||
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct); // empty for OPC UA
|
||||
}
|
||||
|
||||
public sealed record BrowseNode(
|
||||
string NodeId, // address persisted on commit
|
||||
string DisplayName,
|
||||
BrowseNodeKind Kind, // Folder | Leaf
|
||||
bool HasChildrenHint);
|
||||
|
||||
public sealed record AttributeInfo(
|
||||
string Name, // e.g. "DownloadPath"
|
||||
string DriverDataType,
|
||||
bool IsArray,
|
||||
string SecurityClass); // FreeAccess | Operate | Tune | Configure | ViewOnly
|
||||
|
||||
public enum BrowseNodeKind { Folder, Leaf }
|
||||
```
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
1. Razor picker body calls `BrowserSessionService.OpenAsync(driverType, formJson)`
|
||||
2. Service resolves `IDriverBrowser` from DI by driver type, calls `OpenAsync(json)`
|
||||
3. Returns `IBrowseSession`; service registers it in `BrowseSessionRegistry` under a new `Guid` token
|
||||
4. Razor stores token, calls `RootAsync(token)` to populate the initial tree
|
||||
5. Each subsequent expand-click calls `ExpandAsync(token, nodeId)`
|
||||
6. Picker body's `IAsyncDisposable.DisposeAsync` fires `CloseAsync(token)` on tear-down
|
||||
7. `BrowseSessionReaper` (`IHostedService`) ticks every 30s, evicts any session where `(UtcNow - LastUsedUtc) > 2 min`, awaits `DisposeAsync`
|
||||
|
||||
The session genuinely has no value to other cluster nodes — it's tied to one circuit. Hosting it in-process avoids cross-cluster Ask latency on every folder click.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components
|
||||
|
||||
### New projects
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | OPC UA browser impl + session |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/` | Galaxy browser impl + session |
|
||||
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/` | Unit tests (use opc-plc fixture) |
|
||||
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/` | Unit tests (fake transport) |
|
||||
|
||||
Driver-specific browsers live in **sibling** projects so AdminUI doesn't drag the runtime `Driver.*` projects (and their full SDK chains) through a transitive reference.
|
||||
|
||||
### New abstractions
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IDriverBrowser.cs` | Per-driver factory |
|
||||
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs` | Session contract |
|
||||
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs` | + `BrowseNodeKind` enum + `AttributeInfo` |
|
||||
|
||||
### AdminUI plumbing
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Server/.../AdminUI/Browsing/BrowseSessionRegistry.cs` | Singleton, `ConcurrentDictionary<Guid, IBrowseSession>` |
|
||||
| `src/Server/.../AdminUI/Browsing/BrowseSessionReaper.cs` | `IHostedService`, 30s tick, 2 min idle TTL |
|
||||
| `src/Server/.../AdminUI/Browsing/IBrowserSessionService.cs` | Scoped DI service for Razor |
|
||||
| `src/Server/.../AdminUI/Browsing/BrowserSessionService.cs` | Impl: resolve driver, register session, enforce per-call timeouts |
|
||||
| `src/Server/.../AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor` | Shared lazy tree component with per-node text filter |
|
||||
|
||||
### Modified files
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `src/Server/.../Pickers/OpcUaClientAddressPickerBody.razor` | Add Browse button + DriverBrowseTree; keep manual entry |
|
||||
| `src/Server/.../Pickers/GalaxyAddressPickerBody.razor` | Same shape + side-panel for attribute pick |
|
||||
| `src/Server/.../AdminUI/Program.cs` | Register `IDriverBrowser` services + registry + reaper |
|
||||
| `src/Drivers/.../OpcUaClient.Contracts/NamespaceMap.cs` | Extract from runtime `Driver.OpcUaClient` for shared use |
|
||||
| `ZB.MOM.WW.OtOpcUa.slnx` | Add the four new projects |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data flow
|
||||
|
||||
**Open → tree → pick** (OpcUaClient as worked example; Galaxy identical except attribute side-panel before commit):
|
||||
|
||||
```
|
||||
Razor picker body BrowserSessionService IDriverBrowser Remote
|
||||
| | | |
|
||||
click Browse ────────► OpenAsync(driverType, json) ─► OpenAsync(json) ────────► connect + activate session
|
||||
| ◄──────────────── token (Guid) ◄───── ISession |
|
||||
| | | |
|
||||
render tree ─────────► RootAsync(token) ─────────────► session.RootAsync ─────► BrowseAsync(ObjectsFolder)
|
||||
| ◄──────────────── BrowseNode[] ◄───── refs |
|
||||
| | | |
|
||||
click folder ────────► ExpandAsync(token, nodeId) ──► session.ExpandAsync ───► BrowseAsync(nodeId)
|
||||
| ◄──────────────── BrowseNode[] ◄───── refs |
|
||||
| | | |
|
||||
click leaf + commit ─► CloseAsync(token) ─────────► session.DisposeAsync ───► CloseSession
|
||||
| | | |
|
||||
```
|
||||
|
||||
**Galaxy two-stage attribute pick:** after the user selects an object (Folder) in the tree, the picker body calls `AttributesAsync(token, tagName)` and renders the result as a side-panel. The user picks an attribute; the committed address is `tag_name.AttributeName`.
|
||||
|
||||
**Stable address format:**
|
||||
- OpcUaClient: `nsu=<uri>;<localid>` via `NamespaceMap.ToStableReference` — survives remote namespace-table reorder across restarts
|
||||
- Galaxy: `tag_name` (the globally unique system name) — already stable by definition
|
||||
|
||||
**Per-node text filter:** purely client-side over the already-loaded `node.Children`. No round-trip on filter input.
|
||||
|
||||
---
|
||||
|
||||
## 4. OpcUaClient browser specifics
|
||||
|
||||
### Connection
|
||||
- Reuses `OpcUaClientDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
|
||||
- Builds a **separate** `ApplicationConfiguration` from the runtime driver — PKI root at `%LocalAppData%/OtOpcUa/adminui-browse-pki/` (separate cert store)
|
||||
- `ApplicationName = "OtOpcUa AdminUI Browse"`, `ApplicationUri = "urn:OtOpcUa:AdminUI:Browse"`
|
||||
- Endpoint selection: same `DiscoveryClient.GetEndpointsAsync` → filter `(policy, mode)` as the runtime driver
|
||||
- One endpoint only (no failover) — interactive use; user retries with different URL on failure
|
||||
- Bounded by `OpcUaClientDriverOptions.PerEndpointConnectTimeout` (clamped [5, 30]s)
|
||||
|
||||
### Namespace map
|
||||
- `NamespaceMap` class extracted to `OpcUaClient.Contracts` so both runtime and Browser projects share one impl
|
||||
- Browser builds the map from the live session on open; uses `ToStableReference` for outbound NodeIds; uses `TryResolve` for inbound
|
||||
|
||||
### Lazy browse
|
||||
- One level per click using `Session.BrowseAsync` + `BrowseNextAsync` continuation-point loop
|
||||
- `BrowseDescriptionCollection` filters to `NodeClass.Object | NodeClass.Variable`, `ResultMask = BrowseName | DisplayName | NodeClass`
|
||||
- `BrowseNode.HasChildrenHint = (Kind == Folder)` — heuristic; saves a per-node round-trip
|
||||
- Inside-session calls guarded by `SemaphoreSlim _gate` (same pattern as runtime driver — OPC UA `Session.BrowseAsync` not thread-safe)
|
||||
|
||||
### Cert handling
|
||||
- `AutoAcceptCertificates = true` honored with parity to runtime + log warning + per-session unwire on dispose
|
||||
- `AutoAcceptCertificates = false` + untrusted cert → `OpenAsync` fails with SDK error message in the UI
|
||||
|
||||
### Reconnect handling
|
||||
- None. Browse sessions are short-lived (2 min idle TTL). Keep-alive failure → UI surfaces error chip → user re-clicks Browse.
|
||||
|
||||
---
|
||||
|
||||
## 5. Galaxy browser specifics
|
||||
|
||||
### Connection
|
||||
- Reuses `GalaxyDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
|
||||
- Opens `MxGatewaySession` with `ClientName = "OtOpcUa-AdminUI-Browse"` — distinct from runtime driver's name so the gateway can attribute load
|
||||
- Per-call gateway client built via `session.GalaxyRepository(opts.GalaxyName)`
|
||||
|
||||
### Lazy browse
|
||||
- Root: `client.BrowseAsync(new BrowseChildrenOptions(), ct)` → `IReadOnlyList<LazyBrowseNode>`
|
||||
- Expand: cached `LazyBrowseNode` lookup by `tag_name`, then `node.ExpandAsync(ct)` (gateway client handles paging internally)
|
||||
- No internal gate — `LazyBrowseNode.ExpandAsync` already has its own lock; gateway client is thread-safe across distinct calls
|
||||
|
||||
### Two-stage attribute pick
|
||||
- Galaxy `BrowseNode.Kind` is always `Folder` — leaves don't exist at tree level
|
||||
- When the user clicks an object node, picker body calls `AttributesAsync(token, tagName)` and shows the result as a side-panel listing `(Name, DriverDataType, IsArray, SecurityClass)`
|
||||
- On attribute click, committed address is `$"{tagName}.{attrName}"`
|
||||
- Backing call: either `BrowseChildrenOptions { IncludeAttributes = true }` filtered to the GobjectId, or a dedicated `GetAttributesAsync(GobjectId, ct)` — to be confirmed during plan write against the gateway client surface
|
||||
|
||||
### Filters in v1
|
||||
- Per-node text filter (client-side) for tree navigation
|
||||
- Server-side filters (`TagNameGlob`, `AlarmBearingOnly`, `HistorizedOnly`) deferred to a follow-up — easy to add later without breaking the wire (the session is constructed today with `new BrowseChildrenOptions()`)
|
||||
|
||||
---
|
||||
|
||||
## 6. Error handling, timeouts, TTL
|
||||
|
||||
### Failures
|
||||
- `OpenAsync` → catches `Exception`, logs Info, returns typed `BrowseOpenResult(Ok: false, Message, Token: Empty)`. UI shows red chip with truncated SDK message
|
||||
- `ExpandAsync` / `AttributesAsync` → same shape per-call. Failed branch shows error chip; rest of tree intact; session stays alive
|
||||
- `BrowseSessionNotFoundException` when token unknown (session reaped or never existed)
|
||||
|
||||
### Timeouts
|
||||
- Per-call expand/attributes: **20 s** via `CTS.CreateLinkedTokenSource(callerCt)` in `BrowserSessionService`
|
||||
- Session open: **30 s** ceiling; OPC UA reuses `PerEndpointConnectTimeout` (default 10 s), Galaxy hardcodes 30 s for `MxGatewaySession.OpenAsync`
|
||||
|
||||
### TTL & reaping
|
||||
- `LastUsedUtc` set on every `RootAsync`/`ExpandAsync`/`AttributesAsync`
|
||||
- Reaper: `IHostedService` with `PeriodicTimer(30s)`. On each tick: snapshot keys; for any session with `(UtcNow - LastUsedUtc) > 120s`: `TryRemove` then `await DisposeAsync` outside the dictionary
|
||||
- Concurrent `ExpandAsync` racing eviction → caller catches closed-session error → service translates to `BrowseSessionNotFoundException`
|
||||
- On AdminUI shutdown: `StopAsync` walks the registry once and disposes all sessions
|
||||
|
||||
### Concurrency
|
||||
- `BrowseSessionRegistry` = `ConcurrentDictionary<Guid, IBrowseSession>` — no extra lock
|
||||
- OpcUaClient session serializes browse on `SemaphoreSlim`; Galaxy session relies on its internal locks
|
||||
|
||||
### Component dispose
|
||||
- Razor picker body implements `IAsyncDisposable`
|
||||
- Fires `CloseAsync(token)` fire-and-forget (no await) so circuit teardown isn't blocked by a gRPC roundtrip
|
||||
- Reaper is the safety net if dispose doesn't fire
|
||||
|
||||
### Logging
|
||||
- Serilog. Info at open + close, Debug at close-with-reason (`user-close | idle-ttl | shutdown`), Info on failure
|
||||
- No per-expand logging (noise)
|
||||
|
||||
### Audit trail
|
||||
- None — browse is read-only and doesn't mutate config or driver state (matches probe pattern)
|
||||
|
||||
---
|
||||
|
||||
## 7. Security & auth
|
||||
|
||||
### Role gating
|
||||
- Browse button gated by existing `DriverOperator` LDAP policy — same as Reconnect/Restart in `DriverStatusPanel`
|
||||
- Picker bodies check policy in `OnInitializedAsync` via `IAuthorizationService` and `AuthenticationStateProvider`
|
||||
- Manual entry stays available regardless of role
|
||||
|
||||
### Credentials in JSON
|
||||
- Form JSON posted to `BrowserSessionService.OpenAsync` contains plaintext passwords / API keys — same as the existing `TestDriverConnect` probe
|
||||
- JSON is deserialized into typed Options → used to build SDK config → both released; no `_lastConfigJson` cached field anywhere in the registry or session impls
|
||||
- Browse session tokens are `Guid.NewGuid()` and only ever cross the authenticated Blazor circuit
|
||||
|
||||
### Cert handling
|
||||
- `AutoAcceptCertificates = true` honored with log warning + per-session unwire on dispose
|
||||
- Browse PKI store separate from runtime PKI — browse-time accept doesn't poison the runtime driver's trust store
|
||||
|
||||
### Rate limiting
|
||||
- None. DriverOperator role gating + 2-minute TTL is the budget. A bad actor with DriverOperator already has Reconnect/Restart capability
|
||||
|
||||
### Multi-replica AdminUI
|
||||
- Sticky cookies (already configured via Traefik) pin a user to one replica → `BrowseSessionRegistry` is always co-located with the circuit that created the token
|
||||
- Failover → token invalid on new replica → UI re-opens gracefully
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### Unit tests — per-driver browsers
|
||||
- `tests/Drivers/.../OpcUaClient.Browser.Tests/`: against opc-plc at `opc.tcp://10.100.0.35:50000`. `OpcUaClientBrowseSessionTests`, `OpcUaClientDriverBrowserTests` (bad endpoint, auth rejected, bad JSON)
|
||||
- `tests/Drivers/.../Galaxy.Browser.Tests/`: fake `IGalaxyRepositoryClientTransport` (precedent in gateway-client repo). `GalaxyBrowseSessionTests`, `GalaxyDriverBrowserTests`
|
||||
|
||||
### Unit tests — AdminUI plumbing (added to existing `tests/Server/AdminUI.Tests/`)
|
||||
- `BrowseSessionRegistryTests`: register/get/remove, concurrent registration
|
||||
- `BrowseSessionReaperTests`: virtual time, idle eviction, non-idle preservation, eviction-vs-in-flight-expand race
|
||||
- `BrowserSessionServiceTests`: open→root→expand→close, unknown driver type, per-call timeout enforced
|
||||
|
||||
### Component tests
|
||||
- `DriverBrowseTree` lazy-expand contract with fake `IBrowserSessionService`; per-node filter filters DOM but does not call ExpandAsync; click caching
|
||||
- Picker bodies: Browse button hidden when `!_canOperate`; manual entry still works
|
||||
|
||||
### Integration tests (opt-in, fixture-gated)
|
||||
- `tests/Drivers/.../OpcUaClient.Browser.IntegrationTests/`: end-to-end against opc-plc, 3-level expand + round-trip resolve. Skipped unless `OPCUA_SIM_ENDPOINT` set
|
||||
- No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred)
|
||||
|
||||
### Specific regression tests
|
||||
- Namespace-stable round-trip: open → browse → take returned NodeId string → `ExpandAsync(string)` → must resolve back to same NodeId
|
||||
- TTL reaper racing live ExpandAsync: `TryRemove` while expand is in-flight → safe, translates to `BrowseSessionNotFoundException`
|
||||
|
||||
### Verification at PR time
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean
|
||||
- `dotnet test tests/Server/.../AdminUI.Tests/` green (existing 51 + new ~12)
|
||||
- `dotnet test tests/Drivers/.../OpcUaClient.Browser.Tests/` with `lmxopcua-fix up opcuaclient`
|
||||
- `dotnet test tests/Drivers/.../Galaxy.Browser.Tests/` (no fixture)
|
||||
- Manual smoke: run AdminUI, edit an OpcUaClient driver, click Browse against opc-plc, pick a variable, verify the stored NodeId reads cleanly via Client CLI
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation sequencing (for plan-writing)
|
||||
|
||||
Suggested phase split — each phase shippable + reviewable independently:
|
||||
|
||||
1. **Phase 1 — Abstractions.** Add `IDriverBrowser`, `IBrowseSession`, `BrowseNode`, `AttributeInfo`, `BrowseNodeKind` to Commons. Empty build.
|
||||
2. **Phase 2 — Extract NamespaceMap.** Move from runtime `Driver.OpcUaClient` to `Driver.OpcUaClient.Contracts`; update runtime ref.
|
||||
3. **Phase 3 — OpcUaClient browser.** New `Driver.OpcUaClient.Browser` project; impl + unit tests against opc-plc.
|
||||
4. **Phase 4 — Galaxy browser.** New `Driver.Galaxy.Browser` project; impl + unit tests with fake transport. Confirm attribute-fetch API surface on `GalaxyRepositoryClient`.
|
||||
5. **Phase 5 — AdminUI plumbing.** `BrowseSessionRegistry`, `BrowseSessionReaper`, `BrowserSessionService`, DI wire-up in `Program.cs`. Unit tests.
|
||||
6. **Phase 6 — Shared `DriverBrowseTree.razor`.** Lazy tree component with per-node filter. Component tests with fake service.
|
||||
7. **Phase 7 — Wire pickers.** Update `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` to use `DriverBrowseTree` + DriverOperator gating + (Galaxy) attribute side-panel. Manual smoke test.
|
||||
8. **Phase 8 — Integration test + docs.** Opt-in opc-plc integration suite, design doc cross-references in `docs/`, `CLAUDE.md` (or `docs/security.md`) updates if needed.
|
||||
|
||||
---
|
||||
|
||||
## Decisions table
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | Ad-hoc browse using form JSON | Mirrors `TestDriverConnect` probe; works for new drafts and existing drivers uniformly |
|
||||
| 2 | Tree + lazy load both drivers | Galaxy gateway just shipped `LazyBrowseNode.ExpandAsync` — symmetric UX possible |
|
||||
| 3 | AdminUI-hosted via `IDriverBrowser` factory | Browse is interactive (≥10 calls/session); cross-cluster Ask hop would multiply latency; session has no value to other nodes |
|
||||
| 4 | Sibling `*.Browser` projects | Keep AdminUI from pulling runtime `Driver.*` projects' SDK chains |
|
||||
| 5 | `NamespaceMap` to `OpcUaClient.Contracts` | Shared between runtime + browser, no new project needed |
|
||||
| 6 | Separate browse PKI store | Browse-time cert accept must not poison runtime driver's trust store |
|
||||
| 7 | Per-node client-side text filter (v1) | Quick UX win; server-side filters deferred |
|
||||
| 8 | 2 min idle TTL, 30s reaper tick | Matches typical user cadence; bounds resource exposure |
|
||||
| 9 | 20 s per-call / 30 s open timeouts | Interactive feel; longer hangs almost always mean broken remote |
|
||||
| 10 | DriverOperator role gating | Live remote connection is operationally privileged; matches Reconnect/Restart precedent |
|
||||
| 11 | No audit trail | Browse is read-only; matches probe pattern |
|
||||
| 12 | Galaxy two-stage attribute side-panel | One modal, no extra clicks vs. two-modal flow |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-28-driver-browsers-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Phase 1 — Add IDriverBrowser/IBrowseSession/BrowseNode to Commons", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Phase 2 — Extract NamespaceMap to OpcUaClient.Contracts", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Phase 3a — Scaffold Driver.OpcUaClient.Browser project", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: Phase 3b — Implement OpcUaClientBrowseSession", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Phase 3c — Implement OpcUaClientDriverBrowser factory", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: Phase 3d — OpcUaClient.Browser tests (opc-plc fixture)", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Phase 4a — Scaffold Driver.Galaxy.Browser project", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Phase 4b — Implement GalaxyBrowseSession", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: Phase 4c — Implement GalaxyDriverBrowser factory", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: Phase 4d — Galaxy.Browser tests (fake transport)", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 11: Phase 5a — BrowseSessionRegistry + reaper + service", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 12, "subject": "Task 12: Phase 5b — Wire DI in AddAdminUI()", "status": "pending", "blockedBy": [5, 9, 11]},
|
||||
{"id": 13, "subject": "Task 13: Phase 5c — Tests for registry, reaper, service", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 14, "subject": "Task 14: Phase 6 — Shared DriverBrowseTree.razor", "status": "pending", "blockedBy": [12]},
|
||||
{"id": 15, "subject": "Task 15: Phase 7a — Wire OpcUaClient picker to browser", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16: Phase 7b — Wire Galaxy picker + attribute side-panel", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 17, "subject": "Task 17: Phase 8a — opc-plc integration test", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 18, "subject": "Task 18: Phase 8b — Manual smoke + CLAUDE.md update", "status": "pending", "blockedBy": [13, 15, 16, 17]}
|
||||
],
|
||||
"lastUpdated": "2026-05-28T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Design — Complete AdminUI deferred follow-ups
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (design); implementation plan to follow
|
||||
**Author:** Joseph Doherty (with Claude Code)
|
||||
|
||||
## Background
|
||||
|
||||
The AdminUI carried a family of "deferred / Phase C.2 follow-up" notes. A prior
|
||||
change stripped the stale *rendered roadmap banners* from the cluster list pages.
|
||||
Three remaining note groups were investigated to decide what real work they hide:
|
||||
|
||||
- **Group 1 — driver-page inline notes** ("list-editor coming in a follow-up
|
||||
phase" for tags/devices/endpoints; "typed-form-ifying Polly is a follow-up").
|
||||
→ **Real pending UI work.**
|
||||
- **Group 2 — RoleGrants** ("UI-driven editing of the mapping is deferred — it
|
||||
implies a config-reload mechanism that doesn't exist yet"). → **Real work; half
|
||||
the infra already exists.**
|
||||
- **Group 3 — source comments** (F15 Razor migration, F16 FleetStatusHub bridge,
|
||||
"Phase 4" identity section, `TODO(3.3/3.4)` route collision). → **~90% stale**;
|
||||
the referenced work already shipped (the F16 bridge is wired; the legacy
|
||||
`DriverEdit.razor` no longer exists). Only the Polly typed form is real, and it
|
||||
is already counted in Group 1.
|
||||
|
||||
### Key facts established during exploration
|
||||
|
||||
- **Driver-embedded tag/device lists in `DriverConfig` JSON are the runtime source
|
||||
of truth.** Driver factories deserialize them and poll exactly those rows; the
|
||||
canonical `Tag` table is orthogonal (OPC UA browse-tree only, never read by
|
||||
drivers). So inline editors are meaningful, not redundant — editing them changes
|
||||
what the driver polls on the next publish/reinitialize.
|
||||
- **Resilience** already has a strongly-typed model: `DriverResilienceOptions`
|
||||
(`BulkheadMaxConcurrent`, `BulkheadMaxQueue`, `RecycleIntervalSeconds`,
|
||||
`CapabilityPolicies: {DriverCapability → (TimeoutSeconds, RetryCount,
|
||||
BreakerFailureThreshold)}`) with tier A/B/C defaults via `GetTierDefaults(tier)`
|
||||
and a `DriverResilienceOptionsParser`. The stored JSON is an *override* shape;
|
||||
null/absent keys fall back to tier defaults.
|
||||
- **LDAP role map**: the `LdapGroupRoleMapping` entity + migration +
|
||||
`ILdapGroupRoleMappingService` (CRUD) already exist but are **not wired** into
|
||||
login. `LdapAuthService` still reads the static appsettings `GroupToRole`
|
||||
(`Dictionary<string,string>`). `RoleGrants.razor` is read-only.
|
||||
- **Testing**: no bUnit. Established pattern = test `FromOptions`/`ToOptions`
|
||||
round-trips (xUnit + Shouldly in `AdminUI.Tests`) and services with in-memory EF
|
||||
(`Configuration.Tests`).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Scope:** full build — all real follow-ups in Groups 1 & 2, plus Group 3
|
||||
comment cleanup.
|
||||
- **List-editor UX:** modal-per-row with a shared shell component.
|
||||
- **LDAP reload semantics:** DB-backed, **live on the user's next sign-in**
|
||||
(per-login DB query; no restart, no new infra). appsettings `GroupToRole` becomes
|
||||
a bootstrap **fallback** layer.
|
||||
- **Roles are GLOBAL.** No cluster-level permissions / no per-cluster enforcement
|
||||
(explicitly chosen for simplicity, reversing an earlier cluster-scoping answer).
|
||||
Every `LdapGroupRoleMapping` row is `IsSystemWide=true`, `ClusterId=null`.
|
||||
|
||||
## Workstreams
|
||||
|
||||
### WS1 — Driver collection editors (modal-per-row + shared shell)
|
||||
|
||||
- New generic `CollectionEditor<TRow>` component in `Components/Shared/Drivers/`:
|
||||
compact read-only table + `[+ Add]` / per-row `Edit` / `Delete`, and a Bootstrap
|
||||
modal editing a **working copy** of a row (commit on modal-Save, discard on
|
||||
Cancel). Parameters: `List<TRow> Items` (bound), header fragment, read-only-cells
|
||||
fragment, modal-body fragment, `NewRow` factory, optional `Validate` delegate.
|
||||
- Each driver page swaps its read-only `<pre>` for a `CollectionEditor` supplying
|
||||
its own columns + modal fields. Edits mutate the in-memory `List<T>` already in
|
||||
the page's `FormModel`; the page's existing **Save** serializes it into
|
||||
`DriverConfig` — no new persistence path.
|
||||
- Coverage: tags (Modbus, AbCip, AbLegacy, TwinCAT, S7, FOCAS); devices (AbCip,
|
||||
AbLegacy, TwinCAT, FOCAS); endpoints (OpcUaClient).
|
||||
- **Errors/validation:** required fields, duplicate Name within list,
|
||||
driver-specific address format; delete confirm; list mutates only on valid commit.
|
||||
- **Testing:** per-driver `NewRow` factories + `Validate` methods unit-tested
|
||||
directly; existing `*FormSerializationTests` extended for add/remove via the form
|
||||
model. Modal interaction verified manually via `/run`.
|
||||
|
||||
### WS2 — Resilience typed form
|
||||
|
||||
- Replace the textarea in `DriverResilienceSection.razor` with a typed form bound to
|
||||
a new mutable `ResilienceFormModel` (all fields nullable; null = tier default):
|
||||
bulkhead concurrent/queue, recycle interval, and an 8-capability grid (Read,
|
||||
Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead)
|
||||
of (timeout / retry / breaker-threshold).
|
||||
- `FromJson`/`ToJson` emit only non-null overrides (blank → `null`, preserving the
|
||||
current "null = tier defaults" contract). The section gains a `DriverTier`
|
||||
parameter; each driver page passes its known tier so `GetTierDefaults(tier)`
|
||||
renders as placeholders. A collapsible "raw JSON" view remains as escape hatch.
|
||||
- **Errors:** non-negative / sane-range numeric validation; emitted JSON must
|
||||
re-parse cleanly through `DriverResilienceOptionsParser`.
|
||||
- **Testing:** `ResilienceFormModel` round-trip tests in `AdminUI.Tests` —
|
||||
blank→null, partial-override-preserved, emit→parse-back compatibility.
|
||||
|
||||
### WS3 — Editable LDAP→role map (DB-backed, global, live on next sign-in)
|
||||
|
||||
- `RoleGrants.razor` → full CRUD over `LdapGroupRoleMapping` via the existing
|
||||
`ILdapGroupRoleMappingService`. **Global only**: `IsSystemWide=true`,
|
||||
`ClusterId=null`; no cluster UI. Fields: LDAP group, `AdminRole`
|
||||
(ConfigViewer/ConfigEditor/FleetAdmin), notes. A group may carry several roles
|
||||
(multiple rows). Edit page gated to **FleetAdmin** (add a minimal FleetAdmin
|
||||
authorization policy; confirm existing role-policy plumbing during plan-writing).
|
||||
- Wire the service into `LdapAuthService`: at login → resolve groups →
|
||||
`GetByGroupsAsync` (indexed) → map roles → **merge appsettings `GroupToRole` as a
|
||||
fallback layer** (used when no DB row covers a group). Edits take effect on the
|
||||
user's next sign-in. DB rows authoritative + editable; appsettings entries shown
|
||||
read-only as "fallback."
|
||||
- **Errors:** DB unreachable at login → catch, log, fall back to appsettings;
|
||||
login never blocks. CRUD: no duplicate `(LdapGroup, Role)`; group/role required.
|
||||
- **Testing:** extend `LdapGroupRoleMappingServiceTests` (in-memory EF) for CRUD +
|
||||
dedupe; new `RoleMapper` overload `Map(groups, dbRows, fallbackDict)` unit-tested
|
||||
for merge + fallback precedence + DB-error fallback.
|
||||
|
||||
### WS4 — Cleanup (runs last, after the features exist)
|
||||
|
||||
- **Delete stale comments:** `FleetStatusHub.cs` ("passive channel / until the
|
||||
bridge lands"), `EndpointRouteBuilderExtensions.cs` (F15), `DriverIdentitySection.razor`
|
||||
("Phase 4 / generic DriverEdit"), `DriverEditRouter.razor` + `DriverTypePicker.razor`
|
||||
(`TODO(3.3/3.4)` + the "falls back to legacy DriverEdit" path — verify & clean,
|
||||
legacy file is gone), and update `DriverResilienceSection.razor`'s comment.
|
||||
- **Strip rendered notes** now true: per-driver "list-editor coming in a follow-up
|
||||
phase" notes, the OpcUaClient endpoint note, the resilience "typed-form-ifying
|
||||
Polly is a follow-up" note, and the RoleGrants "UI-driven editing is deferred" note.
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
- **No DB schema change** — `LdapGroupRoleMapping` migration already applied;
|
||||
`DriverConfig`/`ResilienceConfig` columns unchanged.
|
||||
- **Definition of done:** build clean + `dotnet test` green + a `/run` pass
|
||||
exercising the modal editors and role-map CRUD.
|
||||
- **Suggested sequence:** WS1 shared shell + Modbus tags as proof → remaining
|
||||
drivers → WS2 → WS3 → WS4.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-29-adminui-followups.md",
|
||||
"branch": "feat/adminui-followups",
|
||||
"tasks": [
|
||||
{"id": 11, "plan": 1, "subject": "Task 1: Generic CollectionEditor<TRow> component", "status": "pending"},
|
||||
{"id": 12, "plan": 2, "subject": "Task 2: Modbus tag editor (proof) + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 13, "plan": 3, "subject": "Task 3: AbCip device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 14, "plan": 4, "subject": "Task 4: AbLegacy device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 15, "plan": 5, "subject": "Task 5: TwinCAT device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 16, "plan": 6, "subject": "Task 6: FOCAS device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 17, "plan": 7, "subject": "Task 7: S7 tag editor + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 18, "plan": 8, "subject": "Task 8: OpcUaClient endpoint-URL editor + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 19, "plan": 9, "subject": "Task 9: ResilienceFormModel + tests", "status": "pending"},
|
||||
{"id": 20, "plan": 10, "subject": "Task 10: Typed resilience form in DriverResilienceSection", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "plan": 11, "subject": "Task 11: RoleMapper.Merge overload + tests", "status": "pending"},
|
||||
{"id": 22, "plan": 12, "subject": "Task 12: Register ILdapGroupRoleMappingService in DI", "status": "pending"},
|
||||
{"id": 23, "plan": 13, "subject": "Task 13: Wire DB merge into AuthEndpoints.LoginAsync", "status": "pending", "blockedBy": [21, 22]},
|
||||
{"id": 24, "plan": 14, "subject": "Task 14: Add FleetAdmin authorization policy", "status": "pending"},
|
||||
{"id": 25, "plan": 15, "subject": "Task 15: RoleGrants.razor global CRUD (FleetAdmin-gated)", "status": "pending", "blockedBy": [22, 24]},
|
||||
{"id": 26, "plan": 16, "subject": "Task 16: LdapGroupRoleMapping service tests (global CRUD)", "status": "pending"},
|
||||
{"id": 27, "plan": 17, "subject": "Task 17: Delete stale source comments", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 20, 25]},
|
||||
{"id": 28, "plan": 18, "subject": "Task 18: Strip now-true rendered notes", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 25]},
|
||||
{"id": 29, "plan": 19, "subject": "Task 19: Full verification (build + test + /run)", "status": "pending", "blockedBy": [20, 23, 26, 27, 28]}
|
||||
],
|
||||
"lastUpdated": "2026-05-29"
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
# Auth/login alignment with ScadaBridge — design
|
||||
|
||||
> **Status:** approved 2026-05-29. Implementation plan to follow via `writing-plans`.
|
||||
> **Trigger:** browser hitting `http://localhost:9200/` rendered Chrome's `HTTP_RESPONSE_CODE_FAILURE` page because the cookie scheme's `OnRedirectToLogin` event was overridden to return 401 with no body, and the parallel JwtBearer scheme stamped `WWW-Authenticate: Bearer`. ScadaBridge sets `LoginPath` and lets the framework do its built-in browser-vs-AJAX heuristic; OtOpcUa diverged.
|
||||
|
||||
**Goal:** Restore default browser-redirect ergonomics on protected GETs, retire the unused JwtBearer server-side scheme, and externalize cookie config — bringing OtOpcUa's auth structure into parity with ScadaBridge.
|
||||
|
||||
**Architecture:** Single Cookie auth scheme. The JWT keeps minting (via `JwtTokenService`) and validating (in `CookieAuthenticationStateProvider`) as the **cookie payload only**; no `AddJwtBearer`, no parallel `Authorization: Bearer` validation. Cookie config (`Name`, `ExpiryMinutes`, `RequireHttpsCookie`) flows through the existing-but-unused `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step — same pattern ScadaBridge uses.
|
||||
|
||||
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` only (drop `Microsoft.AspNetCore.Authentication.JwtBearer` from the wiring if its only remaining transitive use disappears with this change).
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
### Schemes
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| Cookie (primary) + JwtBearer (parallel) | Cookie only |
|
||||
| `FallbackPolicy` lists both schemes | `FallbackPolicy` lists Cookie only |
|
||||
| `OnRedirectToLogin` overridden to 401 | default behavior: 302 for browsers, 401 for AJAX |
|
||||
| `OnRedirectToAccessDenied` overridden to 403 | default behavior: 302 to `/Account/AccessDenied` (404s today; matches ScadaBridge) |
|
||||
|
||||
### Cookie config — externalized via `OtOpcUaCookieOptions`
|
||||
|
||||
```csharp
|
||||
public sealed class OtOpcUaCookieOptions
|
||||
{
|
||||
public const string SectionName = "Security:Cookie";
|
||||
|
||||
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||
public int ExpiryMinutes { get; set; } = 30;
|
||||
public bool RequireHttpsCookie { get; set; } = true;
|
||||
}
|
||||
```
|
||||
|
||||
Wired into `CookieAuthenticationOptions` via:
|
||||
```csharp
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||
{
|
||||
cookieOpts.Cookie.Name = ourOpts.Value.Name;
|
||||
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(ourOpts.Value.ExpiryMinutes);
|
||||
cookieOpts.SlidingExpiration = true;
|
||||
cookieOpts.Cookie.SecurePolicy = ourOpts.Value.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
if (!ourOpts.Value.RequireHttpsCookie)
|
||||
{
|
||||
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. " +
|
||||
"Cookie travels in cleartext over plain HTTP. Dev-only.");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Endpoint surface — unchanged
|
||||
|
||||
| Path | Auth | Behavior |
|
||||
|---|---|---|
|
||||
| `POST /auth/login` | AllowAnonymous | LDAP auth → SignInAsync(Cookie); JSON callers get 204 / 401 / 503, form posters get 302 + cookie |
|
||||
| `POST /auth/logout` | RequireAuthorization | SignOutAsync(Cookie) |
|
||||
| `GET /auth/ping` | AllowAnonymous (handler-returns 200/401) | Polled by Blazor every 60s |
|
||||
| `POST /auth/token` | RequireAuthorization | Mints JWT for hypothetical external callers (matches ScadaBridge — they keep this even without JwtBearer wired) |
|
||||
|
||||
### Cookie rename
|
||||
|
||||
Old: `OtOpcUa.Auth`. New: `ZB.MOM.WW.OtOpcUa.Auth`. Effect: all sessions in flight at deploy time are invisible to the new handler → users re-prompt for login on next protected GET. No security impact (the old cookie expires per its own sliding window; nothing reads it).
|
||||
|
||||
---
|
||||
|
||||
## 2. Components
|
||||
|
||||
### Files modified
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/Server/.../Security/CookieOptions.cs` | Add `RequireHttpsCookie`; change `Name` default to `ZB.MOM.WW.OtOpcUa.Auth` |
|
||||
| `src/Server/.../Security/ServiceCollectionExtensions.cs` | Drop `using JwtBearer`; delete `ConfigureJwtBearerFromTokenService` class; drop `.AddJwtBearer` + its IPostConfigureOptions registration; drop `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides; add `LoginPath` + `LogoutPath`; add PostConfigure block consuming `OtOpcUaCookieOptions`; remove `JwtBearerDefaults.AuthenticationScheme` from `FallbackPolicy` builder |
|
||||
| `tests/Server/.../Security.Tests/AuthEndpointsIntegrationTests.cs` | Update the `Set-Cookie` assertion on the login-success test from `OtOpcUa.Auth=` → `ZB.MOM.WW.OtOpcUa.Auth=` |
|
||||
|
||||
### Files NOT modified
|
||||
|
||||
| File | Why |
|
||||
|---|---|
|
||||
| `Endpoints/AuthEndpoints.cs` | Endpoint contracts unchanged |
|
||||
| `Jwt/JwtTokenService.cs` | Still mints JWT into cookie payload |
|
||||
| `Blazor/CookieAuthenticationStateProvider.cs` | Still polls `/auth/ping` |
|
||||
| `Ldap/*` | Untouched |
|
||||
| Razor login page | POST target unchanged |
|
||||
| `appsettings*.json` | Defaults are production-safe; no required config edit |
|
||||
|
||||
### Tests added
|
||||
|
||||
Single new file or appended class in `tests/Server/.../Security.Tests/`:
|
||||
|
||||
```csharp
|
||||
public class AuthChallengeTests : AuthEndpointsTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||
{
|
||||
var client = NewClient(allowAutoRedirect: false);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("text/html");
|
||||
var resp = await client.GetAsync("/", Ct);
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Found); // 302
|
||||
resp.Headers.Location!.ToString().ShouldContain("/login");
|
||||
resp.Headers.Location.ToString().ShouldContain("ReturnUrl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Root_anonymous_xhr_GET_returns_401()
|
||||
{
|
||||
var client = NewClient(allowAutoRedirect: false);
|
||||
client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest");
|
||||
var resp = await client.GetAsync("/", Ct);
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
// Framework still writes a Location header alongside the 401 — AJAX clients ignore it.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Framework reality vs. earlier hypothesis:** The ASP.NET Core cookie handler's `IsAjaxRequest` heuristic checks ONLY the `X-Requested-With: XMLHttpRequest` header, NOT the `Accept` content type. A request with `Accept: application/json` but no XHR header is classified as a browser → 302. The third test originally proposed (`Root_anonymous_json_GET_returns_401`) was dropped because it tests behavior the framework doesn't have. ScadaBridge accepts the same framework reality (it doesn't override the heuristic either).
|
||||
|
||||
### Package references
|
||||
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`: remove `<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />` if grep confirms `JwtTokenService` doesn't itself need it (it uses `Microsoft.IdentityModel.Tokens` for validation parameters, separate package).
|
||||
|
||||
---
|
||||
|
||||
## 3. Data flow
|
||||
|
||||
### Anonymous browser hits `/`
|
||||
|
||||
```
|
||||
Browser → GET /
|
||||
Accept: text/html
|
||||
┌──> AuthN: no cookie → unauthenticated
|
||||
├──> AuthZ FallbackPolicy fails
|
||||
└──> Cookie HandleChallengeAsync:
|
||||
- Accept: text/html → browser
|
||||
- 302 Location: /login?ReturnUrl=%2F
|
||||
Browser → GET /login ← redirect followed; login page renders (AllowAnonymous)
|
||||
[user submits form]
|
||||
Browser → POST /auth/login Content-Type: application/x-www-form-urlencoded
|
||||
─── LoginAsync:
|
||||
- LDAP authenticate
|
||||
- SignInAsync(Cookie)
|
||||
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=...
|
||||
- 302 Location: / (or ReturnUrl)
|
||||
Browser → GET / cookie present → AuthZ passes → 200 + Razor render
|
||||
```
|
||||
|
||||
### XHR / fetch hits a protected endpoint without cookie
|
||||
|
||||
```
|
||||
fetch('/api/something') Accept: application/json
|
||||
X-Requested-With: XMLHttpRequest
|
||||
┌──> AuthN: no cookie → unauthenticated
|
||||
├──> AuthZ FallbackPolicy fails
|
||||
└──> Cookie HandleChallengeAsync:
|
||||
- not text/html → API client
|
||||
- 401 (no body, no Location)
|
||||
```
|
||||
|
||||
The cookie handler's built-in `IsAjaxRequest` heuristic is what makes this work — it looks for `X-Requested-With: XMLHttpRequest`. No custom event handler needed. Note: requests with only `Accept: application/json` (no XHR header) are classified as browsers → 302; AJAX callers should set the XHR header to get 401.
|
||||
|
||||
### Logout
|
||||
|
||||
```
|
||||
fetch('/auth/logout', POST) cookie present
|
||||
─── LogoutAsync (RequireAuthorization passes):
|
||||
- SignOutAsync(Cookie)
|
||||
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=; expires=...
|
||||
- 204 (or browser-form: 302 /login)
|
||||
```
|
||||
|
||||
### Old cookie ignored
|
||||
|
||||
Browser holds stale `OtOpcUa.Auth` from a session that predates the deploy. Cookie scheme is now configured for `ZB.MOM.WW.OtOpcUa.Auth` — old cookie is invisible. User treated as anonymous → 302 to `/login`. Old cookie sits in jar until its own sliding window expires (max 30 min); no security risk because nothing reads it.
|
||||
|
||||
### Blazor `/auth/ping` polling
|
||||
|
||||
```
|
||||
CookieAuthenticationStateProvider → GET /auth/ping every 60s
|
||||
cookie present → 200
|
||||
cookie expired/missing → 401
|
||||
Blazor → invalidates auth state → re-render → root [Authorize] fails
|
||||
→ Cookie HandleChallengeAsync → 302 /login
|
||||
```
|
||||
|
||||
Unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error handling
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| Unknown `Accept` (`*/*`, missing, JSON) | Framework default: treated as non-AJAX → 302 to `/login`. The cookie handler's `IsAjaxRequest` only looks at `X-Requested-With`, NOT `Accept`. CLI tools that want a 401 should set `X-Requested-With: XMLHttpRequest`. |
|
||||
| `LoginAsync` bad creds | JSON: `401`. Form: `302 /login?error=…&returnUrl=…`. Handler-returned, unaffected by middleware changes. |
|
||||
| `LoginAsync` LDAP throws | `503 ServiceUnavailable`. Handler-returned. |
|
||||
| `LoginAsync` success | JSON: `204`. Form: `302 /` (or `ReturnUrl`). |
|
||||
| Cookie expires mid-request | Treated as anonymous → 302 to `/login` (browser) or 401 (AJAX). Active users kept alive by `SlidingExpiration = true`. |
|
||||
| `RequireHttpsCookie = false` over HTTPS | Cookie marked `SecurePolicy = SameAsRequest`. Misconfiguration risk; startup logs Warning every boot so it's audible. No validator-refused boot — default is `true`; dev compose explicitly opts out. |
|
||||
| Missing `Security:Cookie` section in config | `.Bind()` no-ops; defaults take over (`Name = ZB.MOM.WW.OtOpcUa.Auth`, `ExpiryMinutes = 30`, `RequireHttpsCookie = true`). Production-safe. |
|
||||
| `[Authorize(Policy="DriverOperator")]` denied for authenticated non-operator | Cookie handler redirects to default `AccessDeniedPath = "/Account/AccessDenied"` which 404s in OtOpcUa. Matches ScadaBridge; rare enough not to be a P0. Follow-up: add a minimal `/access-denied` Razor page. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
### Existing tests pass unchanged
|
||||
|
||||
- `Login_with_invalid_credentials_returns_401` — handler-returned, unaffected
|
||||
- `Login_when_ldap_throws_returns_503` — handler-returned, unaffected
|
||||
- `Ping_anonymous_returns_401` — handler-returned, unaffected
|
||||
- `Ping_after_cookie_login_returns_200` — uses HttpClient cookie container, picks up renamed cookie automatically
|
||||
- `Login_with_cookie_credentials_returns_204_and_sets_cookie` — needs one assertion update (cookie name)
|
||||
|
||||
### Tests added (3 new)
|
||||
|
||||
- `Root_anonymous_browser_GET_redirects_to_login` — asserts 302 + `Location` contains `/login` + `ReturnUrl`
|
||||
- `Root_anonymous_ajax_GET_returns_401` — `X-Requested-With: XMLHttpRequest` → 401, no `Location`
|
||||
(the originally planned `Root_anonymous_json_GET_returns_401` was dropped — see Section 3 framework-reality note above)
|
||||
|
||||
### Removed/orphaned tests
|
||||
|
||||
None expected. The explore phase found no test depending on `ConfigureJwtBearerFromTokenService` or the `WWW-Authenticate: Bearer` response. Grep at plan-write time to confirm.
|
||||
|
||||
### Manual smoke (docker-dev stack)
|
||||
|
||||
1. `http://localhost:9200/` anonymously → expect 302 to `/login?ReturnUrl=%2F` (was: Chrome error page)
|
||||
2. Sign in via the form
|
||||
3. `http://localhost:9200/` authenticated → expect Razor dashboard
|
||||
4. DevTools → Application → Cookies → confirm `ZB.MOM.WW.OtOpcUa.Auth`
|
||||
5. `curl -i http://localhost:9200/` → `302 Found`, Location: `/login?ReturnUrl=%2F`
|
||||
6. `curl -i -H "Accept: application/json" http://localhost:9200/` → `401 Unauthorized`
|
||||
|
||||
### Verification gates at PR time
|
||||
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero new errors (pre-existing 12 unchanged)
|
||||
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green
|
||||
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — all green
|
||||
- Manual Chrome smoke above passes
|
||||
|
||||
---
|
||||
|
||||
## 6. Sequencing (for plan-writing)
|
||||
|
||||
Single-PR feature, but split into reviewable phases:
|
||||
|
||||
1. **Phase 1 — Options class.** Extend `OtOpcUaCookieOptions` with `RequireHttpsCookie` and new `Name` default. Tests unaffected.
|
||||
2. **Phase 2 — Wiring rewrite.** Edit `ServiceCollectionExtensions.cs`: drop JwtBearer, drop event overrides, add `LoginPath`/`LogoutPath`, add PostConfigure consumption of `OtOpcUaCookieOptions`. Update the one existing test assertion. Build + existing Security.Tests green.
|
||||
3. **Phase 3 — New challenge tests.** Add the 3 new redirect/401 tests.
|
||||
4. **Phase 4 — Package cleanup.** Remove `Microsoft.AspNetCore.Authentication.JwtBearer` from csproj if grep confirms no remaining consumer.
|
||||
5. **Phase 5 — Manual smoke + commit.** Restart admin-a/admin-b in docker-dev; verify in Chrome.
|
||||
|
||||
---
|
||||
|
||||
## Decisions table
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | Drop JwtBearer server-side scheme | No in-repo consumer; brought non-redirect 401 + `WWW-Authenticate: Bearer` to browser GETs |
|
||||
| 2 | Keep `JwtTokenService` + `/auth/token` | Token-as-cookie-payload is load-bearing for Blazor; `/auth/token` matches ScadaBridge surface |
|
||||
| 3 | Rename cookie `OtOpcUa.Auth` → `ZB.MOM.WW.OtOpcUa.Auth` | Naming parity with ScadaBridge; one-time forced sign-out acceptable |
|
||||
| 4 | Externalize via existing `OtOpcUaCookieOptions` + PostConfigure | Mirrors ScadaBridge pattern; fixes pre-existing bug where options class was bound but ignored |
|
||||
| 5 | Drop both `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides | Restores framework's browser-vs-AJAX heuristic; ScadaBridge does the same |
|
||||
| 6 | Set `LoginPath = "/login"`, `LogoutPath = "/auth/logout"` | Required for the framework's default redirect to work |
|
||||
| 7 | Accept 404 on `/Account/AccessDenied` for v1 | Matches ScadaBridge; rare path; follow-up to add minimal page |
|
||||
| 8 | Warning-log when `RequireHttpsCookie = false` | Audible misconfig signal; same as ScadaBridge |
|
||||
@@ -0,0 +1,652 @@
|
||||
# Auth/login alignment with ScadaBridge — implementation plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` or `superpowers-extended-cc:subagent-driven-development` to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Match ScadaBridge's single-Cookie auth pattern: drop the unused JwtBearer parallel scheme, restore the framework's default browser-vs-AJAX challenge heuristic, and externalize cookie config through the existing-but-unused `OtOpcUaCookieOptions`.
|
||||
|
||||
**Architecture:** Cookie-only auth. `JwtTokenService` keeps minting JWTs as the cookie payload (Blazor circuit hydration depends on it). Cookie name + idle timeout + HTTPS policy flow through `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step. Endpoint surface (`/auth/login`, `/auth/logout`, `/auth/ping`, `/auth/token`) unchanged.
|
||||
|
||||
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` / xUnit v3 + Shouldly / `Microsoft.AspNetCore.TestHost.TestServer`.
|
||||
|
||||
**Design doc:** `docs/plans/2026-05-29-auth-alignment-design.md` (commit `bc4fce5`). Each task below cites the design section it implements.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
Task 1 (Options class)
|
||||
└─► Task 2 (Wiring rewrite + test assertion update)
|
||||
├─► Task 3 (3 new challenge tests)
|
||||
└─► Task 4 (csproj cleanup)
|
||||
└─► Task 5 (manual smoke + final commit)
|
||||
```
|
||||
|
||||
Tasks 3 and 4 are parallelizable (disjoint files).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Extend `OtOpcUaCookieOptions`
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (Task 2 depends on this)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs`
|
||||
|
||||
**Implements design:** Section 1 (Architecture, "Cookie config — externalized") + Section 2 (Components, file table row 1).
|
||||
|
||||
### Step 1: Replace file contents
|
||||
|
||||
Current file (12 lines):
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
public sealed class OtOpcUaCookieOptions
|
||||
{
|
||||
public const string SectionName = "Security:Cookie";
|
||||
|
||||
/// <summary>Gets or sets the cookie name.</summary>
|
||||
public string Name { get; set; } = "OtOpcUa.Auth";
|
||||
|
||||
/// <summary>Idle sliding window, in minutes (default 30).</summary>
|
||||
public int ExpiryMinutes { get; set; } = 30;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
|
||||
/// <c>Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory></c> step inside
|
||||
/// <c>AddOtOpcUaAuth</c> that copies the values onto <c>CookieAuthenticationOptions</c>.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaCookieOptions
|
||||
{
|
||||
/// <summary>Configuration section name (<c>Security:Cookie</c>).</summary>
|
||||
public const string SectionName = "Security:Cookie";
|
||||
|
||||
/// <summary>
|
||||
/// Auth cookie name. Default uses the <c>ZB.MOM.WW</c> convention; mirrors ScadaBridge's
|
||||
/// <c>ZB.MOM.WW.ScadaBridge.Auth</c>. Changing this invalidates existing sessions on next
|
||||
/// deploy.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||
|
||||
/// <summary>Idle sliding-window length in minutes (default 30).</summary>
|
||||
public int ExpiryMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Require HTTPS for the auth cookie. Default <c>true</c>: cookie is marked
|
||||
/// <c>SecurePolicy = Always</c>. Set to <c>false</c> ONLY for local dev stacks running
|
||||
/// plain HTTP — emits a startup Warning when disabled so the misconfiguration is
|
||||
/// audible.
|
||||
/// </summary>
|
||||
public bool RequireHttpsCookie { get; set; } = true;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Build
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
```
|
||||
Expected: 0 errors, 0 warnings.
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "feat(security): extend OtOpcUaCookieOptions with RequireHttpsCookie + ZB.MOM.WW cookie name default"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- Lines before / after
|
||||
- Build clean
|
||||
- Commit SHA
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `Name` default is `"ZB.MOM.WW.OtOpcUa.Auth"` (NOT `"OtOpcUa.Auth"`)
|
||||
- [ ] `RequireHttpsCookie` field added with default `true` and XML doc explaining the dev-only opt-out
|
||||
- [ ] `ExpiryMinutes` default unchanged at 30
|
||||
- [ ] `SectionName` constant unchanged
|
||||
- [ ] Build clean
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Rewrite auth wiring in `ServiceCollectionExtensions.cs`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Tasks 3 and 4 depend on this)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`
|
||||
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs:93`
|
||||
|
||||
**Implements design:** Section 1 + Section 2 file table rows 2 + 3.
|
||||
|
||||
### Step 1: Read current file
|
||||
|
||||
```bash
|
||||
cat /Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
|
||||
```
|
||||
|
||||
Current shape (relevant excerpt):
|
||||
- `using Microsoft.AspNetCore.Authentication.JwtBearer;` at top
|
||||
- `internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService) : IPostConfigureOptions<JwtBearerOptions>` class (lines ~15-35)
|
||||
- `.AddCookie(o => { ... })` with `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides
|
||||
- `.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { })` chained after AddCookie
|
||||
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>()` after the AddAuthentication block
|
||||
- `FallbackPolicy` builder takes both Cookie + JwtBearer schemes
|
||||
|
||||
### Step 2: Replace the file with the new shape
|
||||
|
||||
The full target file:
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
||||
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
||||
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
||||
/// challenge heuristic).</summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The application configuration root.</param>
|
||||
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
|
||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
.SetApplicationName("OtOpcUa");
|
||||
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||
o.LoginPath = "/login";
|
||||
o.LogoutPath = "/auth/logout";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||
});
|
||||
|
||||
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||
{
|
||||
var v = ourOpts.Value;
|
||||
cookieOpts.Cookie.Name = v.Name;
|
||||
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||
cookieOpts.SlidingExpiration = true;
|
||||
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
if (!v.RequireHttpsCookie)
|
||||
{
|
||||
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
|
||||
"SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
|
||||
"Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
|
||||
}
|
||||
});
|
||||
|
||||
services.AddAuthorization(o =>
|
||||
{
|
||||
o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
// DriverOperator: may issue Reconnect/Restart commands against live driver instances
|
||||
// from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
|
||||
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
||||
o.AddPolicy("DriverOperator", policy =>
|
||||
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
What's gone (vs. the original):
|
||||
- `using Microsoft.AspNetCore.Authentication.JwtBearer;`
|
||||
- `ConfigureJwtBearerFromTokenService` internal class entirely
|
||||
- `.AddJwtBearer(...)` chain after `.AddCookie(...)`
|
||||
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();`
|
||||
- `OnRedirectToLogin` / `OnRedirectToAccessDenied` event overrides
|
||||
- Hardcoded `o.Cookie.Name = "OtOpcUa.Auth"`, `o.SlidingExpiration = true`, `o.ExpireTimeSpan = TimeSpan.FromMinutes(30)`, `o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest`
|
||||
- `JwtBearerDefaults.AuthenticationScheme` from the `FallbackPolicy` builder
|
||||
|
||||
What's added:
|
||||
- `using Microsoft.Extensions.Logging;`
|
||||
- `o.LoginPath = "/login"`, `o.LogoutPath = "/auth/logout"` inside `.AddCookie(...)`
|
||||
- The `services.AddOptions<CookieAuthenticationOptions>(...).Configure<...>(...)` PostConfigure block
|
||||
|
||||
### Step 3: Update the one existing test assertion
|
||||
|
||||
In `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` around line 93:
|
||||
|
||||
```csharp
|
||||
// before
|
||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
|
||||
// after
|
||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
||||
```
|
||||
|
||||
### Step 4: Build + run security tests
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||
```
|
||||
|
||||
Expected: build clean; all Security.Tests pass (the existing 5 AuthEndpointsIntegrationTests + JwtTokenServiceTests + LdapHelperTests + RoleMapperTests).
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "$(cat <<'EOF'
|
||||
refactor(security): drop JwtBearer parallel scheme, externalize cookie config
|
||||
|
||||
Single Cookie auth scheme; framework default challenge restores 302 → /login
|
||||
for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to
|
||||
CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the
|
||||
options class was bound but ignored). Cookie name moves to
|
||||
ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- Net LOC change (additions / deletions)
|
||||
- Build clean
|
||||
- Test count run / passed
|
||||
- Commit SHA
|
||||
- Anything unexpected
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `using Microsoft.AspNetCore.Authentication.JwtBearer;` removed
|
||||
- [ ] `ConfigureJwtBearerFromTokenService` class deleted
|
||||
- [ ] `.AddJwtBearer(...)` call deleted
|
||||
- [ ] `IPostConfigureOptions<JwtBearerOptions>` singleton registration deleted
|
||||
- [ ] `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides deleted
|
||||
- [ ] `LoginPath = "/login"` and `LogoutPath = "/auth/logout"` added inside `.AddCookie(...)`
|
||||
- [ ] PostConfigure block added consuming `OtOpcUaCookieOptions`
|
||||
- [ ] Warning log fires when `RequireHttpsCookie == false`
|
||||
- [ ] `FallbackPolicy` now takes only `CookieAuthenticationDefaults.AuthenticationScheme`
|
||||
- [ ] `DriverOperator` policy unchanged
|
||||
- [ ] Test assertion updated to `ZB.MOM.WW.OtOpcUa.Auth=`
|
||||
- [ ] `dotnet test tests/Server/.../Security.Tests/` all green
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Add browser-vs-AJAX challenge tests
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` (append 3 new test methods + 1 helper)
|
||||
|
||||
**Implements design:** Section 5 "Tests added" + Section 4 "Auth challenge for unknown content type".
|
||||
|
||||
### Context for the implementer
|
||||
|
||||
`AuthEndpointsIntegrationTests` is `IAsyncLifetime`-backed and stands up a `TestServer` with `MapOtOpcUaAuth()` mounted (line 66). The `web.UseEndpoints(e => e.MapOtOpcUaAuth())` wires ONLY the four `/auth/*` endpoints — there is NO root `MapGet("/", ...)` registered. So an anonymous GET to `/` hits the routing pipeline, falls through to a 404 BEFORE auth middleware even challenges.
|
||||
|
||||
**The test harness needs a protected root endpoint.** Add one in `InitializeAsync` inside the `web.UseEndpoints(...)` callback. Then the 3 new tests will exercise the cookie scheme's challenge for that protected route.
|
||||
|
||||
### Step 1: Modify the test host setup
|
||||
|
||||
In `AuthEndpointsIntegrationTests.cs`, change `web.UseEndpoints(...)` (around line 66) from:
|
||||
```csharp
|
||||
app.UseEndpoints(e => e.MapOtOpcUaAuth());
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
app.UseEndpoints(e =>
|
||||
{
|
||||
e.MapOtOpcUaAuth();
|
||||
// Protected root used by AuthChallengeTests below — exercises the cookie
|
||||
// scheme's challenge heuristic without depending on the full Razor host.
|
||||
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Add the three new test methods
|
||||
|
||||
Append at the bottom of the class (before the closing brace), keeping the file's existing summary style and using `TestContext.Current.CancellationToken` via the existing `Ct` property:
|
||||
|
||||
```csharp
|
||||
/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
|
||||
[Fact]
|
||||
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||
{
|
||||
var client = NewClientNoRedirect();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Accept.ParseAdd("text/html");
|
||||
var resp = await client.SendAsync(req, Ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Found);
|
||||
resp.Headers.Location.ShouldNotBeNull();
|
||||
resp.Headers.Location!.OriginalString.ShouldContain("/login");
|
||||
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
|
||||
}
|
||||
|
||||
/// <summary>Anonymous AJAX GET of a protected route returns 401 with no Location.</summary>
|
||||
[Fact]
|
||||
public async Task Root_anonymous_ajax_GET_returns_401()
|
||||
{
|
||||
var client = NewClientNoRedirect();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||
var resp = await client.SendAsync(req, Ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
resp.Headers.Location.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Anonymous JSON GET of a protected route returns 401.</summary>
|
||||
[Fact]
|
||||
public async Task Root_anonymous_json_GET_returns_401()
|
||||
{
|
||||
var client = NewClientNoRedirect();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Accept.ParseAdd("application/json");
|
||||
var resp = await client.SendAsync(req, Ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add the no-redirect client helper
|
||||
|
||||
Right next to the existing `NewClient()` method (line 82):
|
||||
|
||||
```csharp
|
||||
/// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
|
||||
/// Used by challenge tests so we can assert on the 302 / Location directly.</summary>
|
||||
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
|
||||
{
|
||||
BaseAddress = _server.BaseAddress,
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: Run the tests
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||
```
|
||||
|
||||
Expected: existing 5 tests still pass + 3 new tests pass = 8+ total green.
|
||||
|
||||
**If `Root_anonymous_browser_GET_redirects_to_login` returns 200 instead of 302**: HttpClient is still auto-following redirects. Two fixes to try in order:
|
||||
1. Confirm `NewClientNoRedirect` uses `_server.CreateHandler()` (not `CreateClient()`).
|
||||
2. If still wrong, swap to: `var handler = new HttpClientHandler { AllowAutoRedirect = false };` — but TestServer doesn't expose HttpClientHandler directly. The `CreateHandler()` path SHOULD return a non-redirecting handler; if it doesn't, the implementation may need a `DelegatingHandler` wrapper.
|
||||
|
||||
**If `Root_anonymous_browser_GET_redirects_to_login` returns 401 instead of 302**: the cookie scheme isn't classifying `Accept: text/html` as a browser. Inspect Task 2's changes — `OnRedirectToLogin` may not have been fully removed, OR `LoginPath` was not set, OR an `Accept` parsing issue. Look at the response body — if it's empty + 401, the JwtBearer scheme or the override is still in play.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "test(security): add browser-vs-AJAX challenge tests for root path"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- 3 new tests + 1 helper + modified InitializeAsync
|
||||
- Build clean
|
||||
- Test count: existing N + 3 new = N+3 green
|
||||
- Commit SHA
|
||||
- Anything unexpected (e.g. redirect-following behavior of `_server.CreateHandler()`)
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `MapGet("/", ...).RequireAuthorization()` added inside `web.UseEndpoints(...)`
|
||||
- [ ] `NewClientNoRedirect()` helper added
|
||||
- [ ] 3 new `[Fact]` methods added with `TestContext.Current.CancellationToken` via the `Ct` property
|
||||
- [ ] Each test asserts on the exact status + Location header (or absence)
|
||||
- [ ] All tests green
|
||||
- [ ] Existing 5 tests still pass
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Remove `Microsoft.AspNetCore.Authentication.JwtBearer` package reference
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** Task 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj` (delete one line)
|
||||
- Verify: `Directory.Packages.props` — leave the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ... />` entry in place (other projects may consume it).
|
||||
|
||||
**Implements design:** Section 2 "Package references" + Section 6 phase 4.
|
||||
|
||||
### Step 1: Confirm no remaining consumer in the Security project
|
||||
|
||||
```bash
|
||||
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer\|JwtBearer" \
|
||||
/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ \
|
||||
--include="*.cs"
|
||||
```
|
||||
|
||||
Expected: zero matches. (Task 2 removed all uses.) If there are matches, STOP and report — Task 2 was incomplete.
|
||||
|
||||
### Step 2: Remove the PackageReference
|
||||
|
||||
In `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`, find this line (currently around line 13):
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
||||
```
|
||||
Delete it. **Keep** these:
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
```
|
||||
(`JwtTokenService` consumes those for `TokenValidationParameters` + JWT creation respectively — they're not from the JwtBearer authentication package.)
|
||||
|
||||
### Step 3: Check whether ANY other project still references the package
|
||||
|
||||
```bash
|
||||
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer" \
|
||||
/Users/dohertj2/Desktop/OtOpcUa/src/ /Users/dohertj2/Desktop/OtOpcUa/tests/ \
|
||||
--include="*.csproj"
|
||||
```
|
||||
|
||||
If zero results: also remove the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ...>` line from `Directory.Packages.props` (search for it). If one or more other projects still reference it, leave `Directory.Packages.props` alone.
|
||||
|
||||
### Step 4: Restore + build
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet restore src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||
```
|
||||
|
||||
Expected: 0 NEW errors. The known pre-existing 12 errors (OpcUaServer.Tests + Runtime.Tests + AbLegacy.Cli + S7.Cli) remain unchanged.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj \
|
||||
Directory.Packages.props # only if you also removed it from Directory.Packages.props
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "chore(security): drop Microsoft.AspNetCore.Authentication.JwtBearer (unused)"
|
||||
```
|
||||
|
||||
If only the csproj changed: omit `Directory.Packages.props` from the add.
|
||||
|
||||
### Output report
|
||||
|
||||
- Was Directory.Packages.props also touched? Justify based on whether other projects still reference the package.
|
||||
- Build clean (0 new errors)
|
||||
- Commit SHA
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] Confirmed zero `Microsoft.AspNetCore.Authentication.JwtBearer` or `JwtBearer` matches in `src/Server/ZB.MOM.WW.OtOpcUa.Security/**/*.cs` before deletion
|
||||
- [ ] PackageReference removed from Security.csproj
|
||||
- [ ] `Microsoft.IdentityModel.Tokens` and `System.IdentityModel.Tokens.Jwt` kept
|
||||
- [ ] Directory.Packages.props touched ONLY if no other project consumes the package
|
||||
- [ ] Full solution build adds zero new errors
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Manual smoke + final commit
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** none (verification + optional cleanup commit)
|
||||
|
||||
**Implements design:** Section 5 "Manual smoke" + Section 6 phase 5.
|
||||
|
||||
### Step 1: Restart the docker-dev cluster
|
||||
|
||||
The admin nodes need to pick up the new `Microsoft.AspNetCore.TestHost`-side code path AND the new cookie name. Since the in-cluster admin processes run a prior build, force a rebuild + recreate:
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
docker compose -f docker-dev/docker-compose.yml up -d --build admin-a admin-b
|
||||
```
|
||||
|
||||
Wait ~15 s for warm-up. Then:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-dev/docker-compose.yml ps admin-a admin-b
|
||||
```
|
||||
|
||||
Both should show `Up` and `(healthy)` (or `Up` if no healthcheck).
|
||||
|
||||
### Step 2: curl smoke
|
||||
|
||||
```bash
|
||||
# Anonymous browser-shaped GET → 302 to /login with ReturnUrl
|
||||
curl -i -H "Accept: text/html" http://localhost:9200/ 2>&1 | head -12
|
||||
# Expected: HTTP/1.1 302 Found, Location: /login?ReturnUrl=%2F
|
||||
|
||||
# Anonymous AJAX GET → 401
|
||||
curl -i -H "X-Requested-With: XMLHttpRequest" http://localhost:9200/ 2>&1 | head -8
|
||||
# Expected: HTTP/1.1 401 Unauthorized
|
||||
|
||||
# Anonymous JSON GET → 401
|
||||
curl -i -H "Accept: application/json" http://localhost:9200/ 2>&1 | head -8
|
||||
# Expected: HTTP/1.1 401 Unauthorized
|
||||
|
||||
# Login form → 302 with Set-Cookie ZB.MOM.WW.OtOpcUa.Auth
|
||||
curl -i -X POST -d "username=alice&password=alice" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
http://localhost:9200/auth/login 2>&1 | head -15
|
||||
# Expected: HTTP/1.1 302 Found, Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=... (the test stub user may differ — check docker-compose's GLAuth seed for a valid LDAP creds pair)
|
||||
```
|
||||
|
||||
### Step 3: Chrome smoke (via the macbook browser instance from earlier in the session)
|
||||
|
||||
1. Open `http://localhost:9200/` — should redirect to `/login?ReturnUrl=%2F` (not Chrome's error page)
|
||||
2. Sign in via the form
|
||||
3. DevTools → Application → Cookies → confirm cookie name is `ZB.MOM.WW.OtOpcUa.Auth`
|
||||
4. Navigate to `http://localhost:9200/` again — should render the AdminUI dashboard
|
||||
5. Click logout → confirm redirect back to `/login`
|
||||
|
||||
### Step 4: Optional CLAUDE.md update
|
||||
|
||||
If `CLAUDE.md` mentions the old `OtOpcUa.Auth` cookie name anywhere, update to the new `ZB.MOM.WW.OtOpcUa.Auth`. Run:
|
||||
|
||||
```bash
|
||||
grep -n "OtOpcUa\.Auth" /Users/dohertj2/Desktop/OtOpcUa/CLAUDE.md
|
||||
```
|
||||
|
||||
If matches: update them, otherwise skip.
|
||||
|
||||
### Step 5: Final commit (only if Step 4 changed CLAUDE.md)
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add CLAUDE.md
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "docs: update cookie name reference in CLAUDE.md"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- All 4 curl smoke checks passed?
|
||||
- Chrome smoke passed?
|
||||
- CLAUDE.md changed?
|
||||
- Final SHA on master (if any docs commit)
|
||||
- Commit count since this plan started (vs `bc4fce5`)
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `docker compose up -d --build admin-a admin-b` succeeded
|
||||
- [ ] All 4 curl smoke checks return expected status codes
|
||||
- [ ] Chrome smoke shows redirect to `/login`, then dashboard after auth
|
||||
- [ ] Cookie name in DevTools matches `ZB.MOM.WW.OtOpcUa.Auth`
|
||||
- [ ] No new commits left uncommitted in the working tree
|
||||
|
||||
---
|
||||
|
||||
## Verification gates (apply at end of every task)
|
||||
|
||||
- `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/` — 0 errors
|
||||
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green (existing + new)
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — no NEW errors beyond the 12 pre-existing
|
||||
- No untracked files staged accidentally (especially `sql_login.txt`, `pki/`, doc-fix artifacts)
|
||||
|
||||
---
|
||||
|
||||
## Risk hot-spots for reviewers
|
||||
|
||||
1. **TestServer's no-redirect HttpClient.** The plan assumes `new HttpClient(_server.CreateHandler()) { BaseAddress = _server.BaseAddress }` does NOT auto-follow redirects. If it does, the `Root_anonymous_browser_GET_redirects_to_login` test fails with 200 instead of 302. Fix path documented in Task 3 Step 4.
|
||||
2. **Framework default of `Accept: */*` → 302.** Curl's default Accept header is `*/*`, which the framework classifies as browser → 302. Documented behavior, mirrors ScadaBridge; reviewers should not flag the smoke step that uses `Accept: text/html` as redundant — it's the explicit "browser" assertion.
|
||||
3. **Cookie rename invalidates sessions.** The deploy effectively logs every currently-signed-in user out. Document in commit body; the cluster was just restarted on the new API key anyway, so the timing is opportune.
|
||||
4. **`Directory.Packages.props` change is conditional.** Don't touch it if other projects still consume the JwtBearer package. Task 4 has explicit grep guard.
|
||||
5. **`/Account/AccessDenied` 404.** Authenticated users hitting a `DriverOperator`-only route now get a generic 404 page instead of a clean access-denied message. Documented design choice; follow-up to add a Razor page if UX feedback demands it.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-29-auth-alignment-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Extend OtOpcUaCookieOptions", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Rewrite auth wiring + update cookie-name assertion", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Add browser-vs-AJAX challenge tests", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: Remove JwtBearer package reference", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "subject": "Task 5: Manual smoke + final commit", "status": "pending", "blockedBy": [3, 4]}
|
||||
],
|
||||
"lastUpdated": "2026-05-29T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# Documentation Audit — Design
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Status:** Approved (brainstorming complete) → ready for writing-plans
|
||||
**Branch:** `docs/documentation-audit` (off `master` @ `c6d9b20`)
|
||||
|
||||
## Goal
|
||||
|
||||
Perform an in-depth audit of the **live reference documentation** to ensure
|
||||
accuracy and completeness, correcting issues in place and writing
|
||||
documentation for every shipped-but-undocumented feature.
|
||||
|
||||
## Decisions
|
||||
|
||||
These were settled during brainstorming and are not open for re-litigation in
|
||||
the plan:
|
||||
|
||||
| Dimension | Decision |
|
||||
|---|---|
|
||||
| **Corpus** | Live reference docs only — top-level `docs/*.md` current-reference set, `docs/drivers/*.md`, `README.md`, `CLAUDE.md` (32 files). Excludes `docs/v1`, `docs/v2`, `docs/plans`, `docs/reqs`, `docs/v3`, `looseends.md`. |
|
||||
| **Output mode** | Fix in place, single pass → corrected docs + a change summary (delivered in chat, not committed). |
|
||||
| **Checks** | All four dimensions: structural integrity, stale-status reconciliation, code-reality cross-check, completeness gaps. |
|
||||
| **Gap handling** | Fill **every** gap — write documentation for all undocumented shipped features, small or large. |
|
||||
| **Approach** | C — deterministic baseline → code-first inventory → grouped vertical passes. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Historical tiers (`v1/`, `v2/`, `plans/`, `reqs/`, `v3/`, `looseends.md`) — they
|
||||
are point-in-time records and are not edited.
|
||||
- The XML doc-comment pass (handled separately by the `/fixdocs` run on branch
|
||||
`chore/fixdocs-xml-doc-comments`).
|
||||
- Code changes. This is a documentation effort. If the audit finds a genuine
|
||||
**code** bug, it is *flagged in the summary, not fixed*.
|
||||
- Secrets must never be introduced into docs: `sql_login.txt`, `pki/`, and the
|
||||
dev gateway API key stay out of any committed file.
|
||||
|
||||
## Corpus & subsystem grouping
|
||||
|
||||
Phase 1 runs one full-depth pass per group (G1–G4). G5 is the Phase-2
|
||||
reconciliation group.
|
||||
|
||||
| Group | Files |
|
||||
|---|---|
|
||||
| **G1 — Server core & data path** | `OpcUaServer.md`, `AddressSpace.md`, `ReadWriteOperations.md`, `IncrementalSync.md`, `VirtualTags.md`, `ScriptedAlarms.md`, `AlarmTracking.md` |
|
||||
| **G2 — Drivers** | `docs/drivers/`: `README.md`, `Galaxy.md`, `FOCAS.md`, + 7 `*-Test-Fixture.md` (`AbLegacy`, `AbServer`, `FOCAS`, `Modbus`, `OpcUaClient`, `S7`, `TwinCAT`) |
|
||||
| **G3 — Security & operational** | `security.md`, `Redundancy.md`, `Reservations.md`, `ServiceHosting.md`, `StatusDashboard.md` |
|
||||
| **G4 — Client & CLI tooling** | `Client.CLI.md`, `Client.UI.md`, `DriverClis.md`, `Driver.{Modbus,AbCip,AbLegacy,S7,TwinCAT,FOCAS}.Cli.md` |
|
||||
| **G5 — Index & root (reconcile last)** | `docs/README.md`, `CLAUDE.md` |
|
||||
|
||||
**Already-suspected findings** (the design accounts for them; verify during the pass):
|
||||
|
||||
- Top-level `AlarmTracking.md` may be **orphaned** — the README index links to
|
||||
`v1/AlarmTracking.md`, not the top-level file. Resolve in G1.
|
||||
- `StatusDashboard.md` is a **stub pointer** (superseded by `v2/admin-ui.md`).
|
||||
Resolve in G3.
|
||||
- `CLAUDE.md` references both `docs/security.md` and `docs/Security.md` — a
|
||||
**case mismatch** that works on macOS but breaks on the Linux docker host.
|
||||
Resolve in G5.
|
||||
|
||||
## Phase 0 — deterministic baseline + code-first inventory
|
||||
|
||||
Two transient working artifacts produced **before any doc is edited**, kept
|
||||
under a scratch dir and **not committed** (lesson from the fixdocs run, where
|
||||
`OtOpcUa-docs-*.md` cluttered the repo root):
|
||||
|
||||
**(a) Structural checker.** Walks all 32 docs, extracts every markdown link and
|
||||
inline source path (`src/...`, `docs/...`, `scripts/...`, `tests/...`), and
|
||||
resolves each against the filesystem. Output: broken links / dead paths / case
|
||||
mismatches. Deterministic and re-runnable — it is also the Phase-2 exit gate.
|
||||
|
||||
**(b) Feature inventory from source.** Enumerated from code, *not* docs, so
|
||||
"fill every gap" has ground truth:
|
||||
|
||||
- **Drivers** — the driver projects under `src/Drivers/` (+ the
|
||||
`Historian.Wonderware` sidecar).
|
||||
- **Capabilities** — the `Core.Abstractions` interfaces (`IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IAlarmSource`,
|
||||
`IHistoryProvider`, `IHostConnectivityProbe`, `IPerCallHostResolver`).
|
||||
- **Config surface** — `appsettings.json` sections + bound Options classes
|
||||
(Security, Authentication.Ldap, Redundancy, MxAccess, …) and documented env
|
||||
vars (`OTOPCUA_ROLES`, …).
|
||||
- **CLI surface** — command verbs + flags from the `System.CommandLine`
|
||||
definitions in the client + 6 driver CLIs.
|
||||
- **Security profiles** — the values `SecurityProfileResolver` actually
|
||||
resolves.
|
||||
|
||||
Diffing the inventory against the docs yields the completeness worklist (what
|
||||
ships but is not documented) and grounds the code-reality cross-check.
|
||||
|
||||
## Phase 1 — per-group fix methodology
|
||||
|
||||
Each group is a vertical pass. For every doc in the group, all four dimensions
|
||||
are applied in order, then the group is committed together:
|
||||
|
||||
1. **Structural** — apply the doc's Phase-0 link/path findings: repair broken
|
||||
links, repoint moved `src/...` paths to current locations, fix case
|
||||
mismatches, resolve orphans (re-link, merge, or retire), replace stub
|
||||
pointers with real content or a correct pointer.
|
||||
2. **Stale-status** — locate state words / banners (`blocked`, `pending`,
|
||||
`not yet`, `planned`, `TODO`, `as of <date>`) and reconcile each against
|
||||
current reality (source + git history + known facts: v2 feature-complete,
|
||||
native alarms verified working). Rewrite to present-tense truth or delete if
|
||||
obsolete.
|
||||
3. **Code-reality cross-check** — verify every technical claim (namespace,
|
||||
class, file, `appsettings` key, env var, CLI verb/flag, described behavior)
|
||||
against the Phase-0 inventory and a direct source read. **Fixes go to the
|
||||
doc to match the code, never the reverse.** A genuine code bug is flagged in
|
||||
the summary, not changed.
|
||||
4. **Completeness** — take this group's slice of the inventory diff and write
|
||||
the missing docs: small inline additions for a missing key/flag, new
|
||||
sections or whole new pages for an undocumented driver/subsystem. Every new
|
||||
page is linked from its index (`README.md` / `drivers/README.md`).
|
||||
|
||||
**Hard scope rule:** edits land only in the 32 in-scope files. If an in-scope
|
||||
doc links into an out-of-scope tier and the *target moved*, fix the **link in
|
||||
the live doc** — never edit the historical artifact.
|
||||
|
||||
## Phase 2 — reconciliation & validation
|
||||
|
||||
**Cross-doc reconciliation (G5):** `docs/README.md` index integrity (every
|
||||
listed doc exists and is correctly described; newly written docs are added),
|
||||
"superseded by" pointers correct, and `CLAUDE.md` reconciled against reality
|
||||
(the `security.md`/`Security.md` casing, retired-project notes, the docs it
|
||||
names as canonical).
|
||||
|
||||
**Validation — the audit's "tests" are two re-runnable gates plus review:**
|
||||
|
||||
- **Structural gate** — re-run the Phase-0 checker → **zero** broken links /
|
||||
dead paths / case mismatches.
|
||||
- **Completeness gate** — re-run the inventory diff → every shipped feature is
|
||||
documented, or each deliberate exclusion is listed with a reason.
|
||||
- **Spot-verification** — a sample of code-reality fixes re-checked against
|
||||
source with `file:line` citations in the summary.
|
||||
- Each group is a reviewable commit; nothing touches code, secrets, or
|
||||
out-of-scope tiers.
|
||||
|
||||
## Output
|
||||
|
||||
The change summary (in chat, not committed): fixes grouped by dimension, the
|
||||
list of new docs written for completeness, and any code bugs flagged-not-fixed.
|
||||
|
||||
## Brainstorming task references
|
||||
|
||||
Native tasks created during brainstorming: #53 (explore), #54 (clarify), #55
|
||||
(approaches), #56 (present design), #57 (write design doc), #58 (transition to
|
||||
writing-plans).
|
||||
@@ -0,0 +1,329 @@
|
||||
# Documentation Audit Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Audit and fix the 32 live reference docs in place so they are accurate against today's source and complete (every shipped feature documented).
|
||||
|
||||
**Architecture:** Approach C — a deterministic Phase 0 baseline (a re-runnable link/path checker + a code-first feature inventory) feeds grouped vertical passes (G1 server-core, G2 drivers, G3 security/operational, G4 client+CLI), each applying all four audit dimensions per doc, then a Phase 2 reconciliation of the shared index/root docs plus a final corpus-wide gate.
|
||||
|
||||
**Tech Stack:** Markdown docs; a small Python 3 checker script; the OtOpcUa .NET 10 source tree as the ground truth for cross-checking.
|
||||
|
||||
**Design:** `docs/plans/2026-06-03-documentation-audit-design.md` (read it for the decisions; they are settled).
|
||||
|
||||
---
|
||||
|
||||
## Method note (read once)
|
||||
|
||||
This is a **documentation** deliverable — there is no xUnit suite to make red→green. The plan therefore adapts the TDD step shape: each task **identifies findings → applies fixes → verifies with the Phase-0 gate (scoped) → commits**. The executable verification is the structural checker (Task 1) plus per-task acceptance criteria. Do not invent unit tests for prose.
|
||||
|
||||
## Hard rules (apply to EVERY task)
|
||||
|
||||
1. **Scope:** edit ONLY the 32 in-scope files. Never edit out-of-scope tiers (`docs/v1`, `docs/v2`, `docs/plans` except this plan/design, `docs/reqs`, `docs/v3`, `looseends.md`). If an in-scope doc links into an out-of-scope tier and the **target moved**, fix the **link in the live doc** — never the historical artifact.
|
||||
2. **Direction:** docs change to match the code, **never** the reverse. If the code itself looks wrong, append a one-line entry to `.docs-audit/code-bug-flags.md` — do NOT change code.
|
||||
3. **Evidence:** every code-reality correction must be verified against a real source location; record `file:line` in the commit body or `.docs-audit/notes.md`. No fixes from memory or assumption.
|
||||
4. **Git safety:** stage files **explicitly by path**. NEVER `git add .` / `git add -A`. Never stage `sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, or the `.docs-audit/` scratch dir. Never echo the dev gateway API key into a tracked file. No force-push, no `--no-verify`.
|
||||
5. **Branch:** all work on `docs/documentation-audit` (already checked out).
|
||||
|
||||
## Shared procedures (referenced by tasks as "Procedure P / C / Gate")
|
||||
|
||||
### Gate — structural checker
|
||||
|
||||
```bash
|
||||
python3 .docs-audit/check_links.py > .docs-audit/links-report.md 2>.docs-audit/links-summary.txt; cat .docs-audit/links-summary.txt
|
||||
```
|
||||
Exit 0 = zero issues. The report is tab-separated: `file <TAB> kind <TAB> tag <TAB> raw-target <TAB> case-hint`.
|
||||
|
||||
### Procedure P — per-doc audit (apply all four dimensions to one doc)
|
||||
|
||||
1. **Read** the whole doc.
|
||||
2. **Structural** — for each entry for this doc in `.docs-audit/links-report.md`: repair the broken link / repoint the dead `src|tests|scripts|docs/...` path to its verified current location / fix the case mismatch (use the `case-hint` column). Confirm every new target exists on disk.
|
||||
3. **Stale-status** — scan for state words (`blocked`, `pending`, `not yet`, `planned`, `TODO`, `TBD`, `as of <date>`, `will`, `coming`). For each, verify against source + `git log` + known facts (v2 feature-complete; native alarms verified working 2026-05-31). Rewrite to present-tense truth or delete if obsolete.
|
||||
4. **Code-reality cross-check** — for every technical claim (namespace, class, file, `appsettings` key, env var, CLI verb/flag, described behavior), open the cited source and verify. Fix the doc to match; record `file:line` evidence. Flag genuine code bugs to `.docs-audit/code-bug-flags.md`.
|
||||
5. **Inline completeness** — from this doc's slice of `.docs-audit/inventory-diff.md`, add small missing items that belong in an existing section (a missing config key, an undocumented flag, a one-paragraph gap). Whole-new-page gaps are deferred to the group completeness task (Procedure C).
|
||||
6. **Verify** — run the Gate; confirm zero issues attributable to this doc; eyeball that tables/code-fences/lists still render.
|
||||
7. **Commit** this one doc by explicit path: `git add <doc> && git commit -m "docs(audit): <doc> — accuracy + completeness pass"`.
|
||||
|
||||
### Procedure C — per-group completeness & cross-links
|
||||
|
||||
1. Take this group's domain slice of `.docs-audit/inventory-diff.md` (features with **no** doc coverage at all).
|
||||
2. For each, write the documentation: a new page under the appropriate dir, or a new section in the most relevant existing in-scope doc (judgment — prefer extending an existing doc over a thin new page).
|
||||
3. **Group-local index only:** G2 may update `docs/drivers/README.md`. Do **not** touch `docs/README.md` (top-level index) here — append each new top-level page to `.docs-audit/new-pages.md` for Task 26 (G5) to link in one place, avoiding cross-group collisions on the shared index.
|
||||
4. Run the Gate; commit new/edited files by explicit path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — deterministic baseline + code-first inventory
|
||||
|
||||
### Task 1: Structural checker script + initial run
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `.docs-audit/check_links.py` (untracked scratch — never committed)
|
||||
- Create (untracked): `.docs-audit/links-report.md`, `.docs-audit/links-summary.txt`
|
||||
|
||||
**Step 1: Ensure scratch dir is ignored.** If `.docs-audit/` is not already covered by `.gitignore`, add the line `.docs-audit/` to `.gitignore` and commit that one-line change (`git add .gitignore && git commit -m "chore: ignore .docs-audit scratch dir"`). This is the only non-doc file the plan commits.
|
||||
|
||||
**Step 2: Write `.docs-audit/check_links.py`:**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Structural link/path checker for the documentation audit (Phase 0 + final gate).
|
||||
Scans the 32 in-scope live-reference docs, resolves every markdown link and inline
|
||||
src|tests|scripts|docs path against the filesystem, and reports MISSING / CASE-MISMATCH."""
|
||||
import os, re, sys, glob
|
||||
|
||||
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
def in_scope():
|
||||
files = sorted(glob.glob(os.path.join(REPO, "docs", "*.md")))
|
||||
files += sorted(glob.glob(os.path.join(REPO, "docs", "drivers", "*.md")))
|
||||
files += [os.path.join(REPO, "README.md"), os.path.join(REPO, "CLAUDE.md")]
|
||||
return [f for f in files if os.path.isfile(f)]
|
||||
|
||||
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
||||
PATH_RE = re.compile(r"`?((?:src|tests|scripts|docs)/[A-Za-z0-9_./-]+)`?")
|
||||
|
||||
def case_insensitive_hint(path):
|
||||
d, name = os.path.split(path)
|
||||
if not os.path.isdir(d):
|
||||
return None
|
||||
for entry in os.listdir(d):
|
||||
if entry.lower() == name.lower():
|
||||
return os.path.join(d, entry)
|
||||
return None
|
||||
|
||||
def check(f):
|
||||
base = os.path.dirname(f)
|
||||
text = open(f, encoding="utf-8").read()
|
||||
out = []
|
||||
targets = [("link", m.group(1)) for m in LINK_RE.finditer(text)]
|
||||
targets += [("path", m.group(1)) for m in PATH_RE.finditer(text)]
|
||||
for kind, raw in targets:
|
||||
t = raw.split("#")[0].strip()
|
||||
if not t or re.match(r"^[a-z]+://", t) or t.startswith("mailto:"):
|
||||
continue
|
||||
if kind == "link":
|
||||
cand = os.path.normpath(os.path.join(base, t))
|
||||
else:
|
||||
cand = os.path.normpath(os.path.join(REPO, t.rstrip("./")))
|
||||
if os.path.exists(cand):
|
||||
continue
|
||||
hint = case_insensitive_hint(cand)
|
||||
tag = "CASE-MISMATCH" if hint else "MISSING"
|
||||
out.append((os.path.relpath(f, REPO), kind, tag, raw,
|
||||
os.path.relpath(hint, REPO) if hint else ""))
|
||||
return out
|
||||
|
||||
def main():
|
||||
docs = in_scope()
|
||||
issues = [row for f in docs for row in check(f)]
|
||||
for rel, kind, tag, raw, hint in issues:
|
||||
print(f"{rel}\t{kind}\t{tag}\t{raw}\t{hint}")
|
||||
print(f"{len(issues)} issue(s) across {len(docs)} docs", file=sys.stderr)
|
||||
sys.exit(1 if issues else 0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 3: Run it** (Gate). Expected on first run: a non-empty report (at minimum the `CLAUDE.md` → `docs/Security.md` case mismatch and the `AlarmTracking.md` orphan situation surface here). Confirm the script runs without a Python traceback and the count printed to stderr matches the report line count.
|
||||
|
||||
**Step 4:** Do NOT commit the script or reports (they are under the now-ignored `.docs-audit/`). Only the `.gitignore` line from Step 1 is committed.
|
||||
|
||||
**Acceptance:** `check_links.py` runs clean (no traceback), emits a tab-separated report, exits non-zero while issues remain. This same command is the per-task and final gate.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Code-first feature inventory + coverage diff
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (broad enumeration — split into sub-runs if needed)
|
||||
**Parallelizable with:** Task 1
|
||||
|
||||
**Files:**
|
||||
- Create (untracked): `.docs-audit/inventory.md`, `.docs-audit/inventory-diff.md`
|
||||
|
||||
**Step 1: Enumerate the shipped surface from source** into `.docs-audit/inventory.md`, grouped by domain so Procedure C can slice it:
|
||||
- **Drivers (G2 domain)** — every family under `src/Drivers/` (`AbCip`, `AbLegacy`, `FOCAS`, `Galaxy`, `Historian.Wonderware`, `Modbus`, `OpcUaClient`, `S7`, `TwinCAT`). For each, note the driver class + which capability interfaces it implements.
|
||||
- **Capabilities (G1 domain)** — the interfaces in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/` (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`, `IPerCallHostResolver`, plus `IDriver*`, `IAddressSpaceBuilder`, `IRediscoverable`).
|
||||
- **Config surface (G3 domain)** — top-level sections across `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings*.json` and their bound Options classes (e.g. `Security`, `Authentication.Ldap`, `Redundancy`, `MxAccess`). List documented env vars (`OTOPCUA_ROLES`, …).
|
||||
- **Security profiles (G3 domain)** — the exact profile strings `SecurityProfileResolver` resolves (grep `src/Server/ZB.MOM.WW.OtOpcUa.Security/`).
|
||||
- **CLI surface (G4 domain)** — command verbs + options from the `System.CommandLine` definitions in `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/` and each driver CLI under `src/Drivers/Cli/`.
|
||||
|
||||
**Step 2: Compute the coverage diff** into `.docs-audit/inventory-diff.md`. For each inventory item, grep the 32 in-scope docs for its primary token; mark `COVERED` / `PARTIAL` / `MISSING`. Helper:
|
||||
```bash
|
||||
grep -RIl --include='*.md' "<token>" docs/*.md docs/drivers/*.md README.md CLAUDE.md
|
||||
```
|
||||
Keep only `PARTIAL`/`MISSING` rows in the diff, tagged with the owning domain (G1–G4). This is the completeness worklist consumed by Procedure P step 5 (small/partial) and Procedure C (missing whole pages).
|
||||
|
||||
**Step 3:** No commit (scratch only).
|
||||
|
||||
**Acceptance:** `inventory.md` lists every shipped driver/capability/config-section/security-profile/CLI-verb with a source location; `inventory-diff.md` enumerates the gaps tagged by domain. A spot-check of 3 random inventory rows resolves to real source.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — grouped vertical passes
|
||||
|
||||
> All Phase 1 tasks are **blockedBy Task 1 and Task 2**. Every per-doc accuracy task edits only its own doc(s) → all are mutually parallelizable (disjoint files). Each group's completeness task (Procedure C) is blockedBy that group's accuracy tasks.
|
||||
|
||||
### G1 — Server core & data path
|
||||
|
||||
### Task 3: OpcUaServer.md
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** all other Phase-1 accuracy tasks (Tasks 4–7, 9–13, 15–18, 20–24)
|
||||
**Files:** Modify `docs/OpcUaServer.md`
|
||||
Apply **Procedure P**. Doc-specific focus: Core/driver-dispatch/Config-DB/generations claims vs `src/Core` + `src/Server`; verify `CapabilityInvoker`, `GenericDriverNodeManager`, generation-diff references resolve.
|
||||
|
||||
### Task 4: AddressSpace.md
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 3, 5–7, 9–13, 15–18, 20–24
|
||||
**Files:** Modify `docs/AddressSpace.md`
|
||||
Apply **Procedure P**. Focus: `GenericDriverNodeManager`, `ITagDiscovery`, `IAddressSpaceBuilder`, `DataTypeMap.cs` path.
|
||||
|
||||
### Task 5: ReadWriteOperations.md + IncrementalSync.md
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3,4,6,7,9–13,15–18,20–24
|
||||
**Files:** Modify `docs/ReadWriteOperations.md`, `docs/IncrementalSync.md`
|
||||
Apply **Procedure P** to each. Focus: `CapabilityInvoker`→`IReadable`/`IWritable`; `sp_ComputeGenerationDiff` + rebuild-on-redeploy.
|
||||
|
||||
### Task 6: VirtualTags.md + ScriptedAlarms.md
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–5,7,9–13,15–18,20–24
|
||||
**Files:** Modify `docs/VirtualTags.md`, `docs/ScriptedAlarms.md`
|
||||
Apply **Procedure P** to each. Focus: `Core.Scripting`/`Core.VirtualTags`/`Core.ScriptedAlarms` (Roslyn sandbox, Part 9 state machine). Cross-check against the named Core projects.
|
||||
|
||||
### Task 7: AlarmTracking.md (orphan resolution)
|
||||
**Classification:** small · **~4 min** · **Parallelizable with:** Tasks 3–6,9–13,15–18,20–24
|
||||
**Files:** Modify `docs/AlarmTracking.md` (and/or decide retirement)
|
||||
**Known finding:** the README index links to `docs/v1/AlarmTracking.md`, not this top-level file → it is likely orphaned. Apply **Procedure P**, then **decide**: (a) if it duplicates the v1 archive, replace its body with a short current-state pointer to the live alarm story (native alarms work end-to-end) + the v1 archive link; or (b) if it carries unique current content, keep & fix it and ensure Task 26 links it from `docs/README.md`. Record the decision in the commit body. Do not delete the file without noting why.
|
||||
|
||||
### Task 8: G1 completeness & cross-links
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** other groups' completeness tasks (14, 19, 25)
|
||||
**blockedBy:** Tasks 3,4,5,6,7
|
||||
**Files:** Create/Modify server-core docs as needed; append new top-level pages to `.docs-audit/new-pages.md`
|
||||
Apply **Procedure C** for the **G1 (capabilities/server-core)** slice of `inventory-diff.md`. Likely candidates: any capability interface or Core subsystem (e.g. `Core.AlarmHistorian`) with no live-doc home.
|
||||
|
||||
### G2 — Drivers
|
||||
|
||||
### Task 9: docs/drivers/README.md (index + capability matrix)
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 3–7,10–13,15–18,20–24
|
||||
**Files:** Modify `docs/drivers/README.md`
|
||||
Apply **Procedure P**. Focus: the eight-driver count + capability matrix vs the actual `src/Drivers/` families and the interfaces each implements (from `inventory.md`). Correct the matrix to match reality.
|
||||
|
||||
### Task 10: docs/drivers/Galaxy.md
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 3–7,9,11–13,15–18,20–24
|
||||
**Files:** Modify `docs/drivers/Galaxy.md`
|
||||
Apply **Procedure P**. Focus: in-process gRPC client → mxaccessgw sidecar; `GalaxyDriver`, `IGalaxyHierarchySource`, `DeployWatcher`, contained-name↔tag-name translation vs `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/`.
|
||||
|
||||
### Task 11: drivers/FOCAS.md + FOCAS-Test-Fixture.md
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–7,9,10,12,13,15–18,20–24
|
||||
**Files:** Modify `docs/drivers/FOCAS.md`, `docs/drivers/FOCAS-Test-Fixture.md`
|
||||
Apply **Procedure P** to each vs `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS`.
|
||||
|
||||
### Task 12: Modbus + AbServer + AbLegacy test-fixture docs
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–7,9–11,13,15–18,20–24
|
||||
**Files:** Modify `docs/drivers/Modbus-Test-Fixture.md`, `docs/drivers/AbServer-Test-Fixture.md`, `docs/drivers/AbLegacy-Test-Fixture.md`
|
||||
Apply **Procedure P** to each. Focus: docker-host endpoints (`10.100.0.35`), fixture compose paths, `lmxopcua` labels vs `tests/.../Docker/` + CLAUDE.md Docker section.
|
||||
|
||||
### Task 13: S7 + TwinCAT + OpcUaClient test-fixture docs
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–7,9–12,15–18,20–24
|
||||
**Files:** Modify `docs/drivers/S7-Test-Fixture.md`, `docs/drivers/TwinCAT-Test-Fixture.md`, `docs/drivers/OpcUaClient-Test-Fixture.md`
|
||||
Apply **Procedure P** to each (same fixture/endpoint focus as Task 12).
|
||||
|
||||
### Task 14: G2 completeness & drivers index
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 8,19,25
|
||||
**blockedBy:** Tasks 9,10,11,12,13
|
||||
**Files:** Create new `docs/drivers/*.md` as needed; Modify `docs/drivers/README.md` (group-local index)
|
||||
Apply **Procedure C** for the **G2 (drivers)** slice. Likely candidates: any `src/Drivers/` family lacking a dedicated doc (e.g. AbCip/AbLegacy/S7/TwinCAT/Modbus/OpcUaClient have CLI docs + fixtures but may lack a driver-overview page like Galaxy/FOCAS). Link any new page from `docs/drivers/README.md`. Top-level links → `.docs-audit/new-pages.md`.
|
||||
|
||||
### G3 — Security & operational
|
||||
|
||||
### Task 15: security.md
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,16–18,20–24
|
||||
**Files:** Modify `docs/security.md`
|
||||
Apply **Procedure P**. Focus: transport-security profile strings (vs `SecurityProfileResolver`), LDAP auth + group→role mapping, ACL trie, role grants, the OTOPCUA0001 analyzer. This is the highest-value accuracy doc — verify every profile/role/config-key against source.
|
||||
|
||||
### Task 16: Redundancy.md
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,15,17,18,20–24
|
||||
**Files:** Modify `docs/Redundancy.md`
|
||||
Apply **Procedure P**. Focus: `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, `RedundancySupport`/`ServerUriArray`/`ServiceLevel`, Prometheus metrics vs `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane`/`Runtime`.
|
||||
|
||||
### Task 17: ServiceHosting.md
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,15,16,18,20–24
|
||||
**Files:** Modify `docs/ServiceHosting.md`
|
||||
Apply **Procedure P**. Focus: single fused `OtOpcUa.Host` binary, `OTOPCUA_ROLES` gating (`admin`/`driver`/both), `AddWindowsService`, the optional Wonderware Historian sidecar vs `src/Server/ZB.MOM.WW.OtOpcUa.Host`.
|
||||
|
||||
### Task 18: Reservations.md + StatusDashboard.md (stub resolution)
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,15–17,20–24
|
||||
**Files:** Modify `docs/Reservations.md`, `docs/StatusDashboard.md`
|
||||
Apply **Procedure P** to `Reservations.md` (ZTag/SAPID external-ID reservations, publish-time claim/release). **StatusDashboard.md is a known stub pointer** (superseded by `v2/admin-ui.md`, which is out of scope): verify the pointer target still exists and the supersession statement is accurate; keep it a clean pointer (do not expand). If `v2/admin-ui.md` moved, fix the link only.
|
||||
|
||||
### Task 19: G3 completeness & cross-links
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 8,14,25
|
||||
**blockedBy:** Tasks 15,16,17,18
|
||||
**Files:** Create/Modify security/operational docs as needed; append top-level pages to `.docs-audit/new-pages.md`
|
||||
Apply **Procedure C** for the **G3 (config/security/operational)** slice — any `appsettings` section, security profile, or operational subsystem with no live-doc coverage.
|
||||
|
||||
### G4 — Client & CLI tooling
|
||||
|
||||
### Task 20: Client.CLI.md
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,15–18,21–24
|
||||
**Files:** Modify `docs/Client.CLI.md`
|
||||
Apply **Procedure P**. Focus: `otopcua-cli` verbs/flags (connect/read/write/browse/subscribe/historyread/alarms/redundancy) vs the `System.CommandLine` defs in `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/`. Every documented command/flag must exist; every shipped command must be documented.
|
||||
|
||||
### Task 21: Client.UI.md
|
||||
**Classification:** small · **~4 min** · **Parallelizable with:** Tasks 3–7,9–13,15–18,20,22–24
|
||||
**Files:** Modify `docs/Client.UI.md`
|
||||
Apply **Procedure P** vs `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` (Avalonia desktop client).
|
||||
|
||||
### Task 22: DriverClis.md (index + shared commands)
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,15–18,20,21,23,24
|
||||
**Files:** Modify `docs/DriverClis.md`
|
||||
Apply **Procedure P**. Focus: the index must list exactly the driver CLIs that ship under `src/Drivers/Cli/`; shared command set matches the common base.
|
||||
|
||||
### Task 23: Driver.Modbus/AbCip/AbLegacy CLI docs
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,15–18,20–22,24
|
||||
**Files:** Modify `docs/Driver.Modbus.Cli.md`, `docs/Driver.AbCip.Cli.md`, `docs/Driver.AbLegacy.Cli.md`
|
||||
Apply **Procedure P** to each vs the matching CLI project under `src/Drivers/Cli/`. Verify verbs/flags + the documented device families.
|
||||
|
||||
### Task 24: Driver.S7/TwinCAT/FOCAS CLI docs
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** Tasks 3–7,9–13,15–18,20–23
|
||||
**Files:** Modify `docs/Driver.S7.Cli.md`, `docs/Driver.TwinCAT.Cli.md`, `docs/Driver.FOCAS.Cli.md`
|
||||
Apply **Procedure P** to each vs the matching CLI project under `src/Drivers/Cli/`.
|
||||
|
||||
### Task 25: G4 completeness & cross-links
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 8,14,19
|
||||
**blockedBy:** Tasks 20,21,22,23,24
|
||||
**Files:** Create/Modify client/CLI docs as needed; append top-level pages to `.docs-audit/new-pages.md`
|
||||
Apply **Procedure C** for the **G4 (client/CLI)** slice — any CLI verb or client surface with no doc coverage.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — reconciliation & final gate
|
||||
|
||||
### Task 26: G5 reconciliation — README index + CLAUDE.md
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** none
|
||||
**blockedBy:** Tasks 8,14,19,25
|
||||
**Files:** Modify `docs/README.md`, `CLAUDE.md`
|
||||
1. **README index integrity:** every doc listed in `docs/README.md` exists and is described correctly; every new page recorded in `.docs-audit/new-pages.md` is added to the right table; resolve the `AlarmTracking.md` link per Task 7's decision; verify all "superseded by" pointers.
|
||||
2. **CLAUDE.md reconciliation:** fix the `docs/security.md` vs `docs/Security.md` **case mismatch** (canonical filename is lowercase `security.md`); verify the docs CLAUDE.md names as canonical exist; reconcile any retired-project / status notes against current reality.
|
||||
3. Run the **Gate**; commit both files by explicit path.
|
||||
|
||||
**Acceptance:** Gate attributes zero issues to `README.md`/`CLAUDE.md`; both `security.md` references use the on-disk casing; every new page is linked.
|
||||
|
||||
### Task 27: Final gate + change summary
|
||||
**Classification:** small · **~4 min** · **Parallelizable with:** none
|
||||
**blockedBy:** Task 26
|
||||
**Files:** none committed (verification + reporting only)
|
||||
1. **Structural gate (corpus-wide):** run the Gate → exit 0, `0 issue(s)`. If any remain, they are unfixed findings — return to the owning doc's task, do not hand-wave.
|
||||
2. **Completeness gate:** re-run the Task-2 coverage diff → every inventory item is `COVERED`, or each remaining gap is listed in the summary with an explicit reason for exclusion (e.g. "out-of-scope tier owns it").
|
||||
3. **Assemble the change summary** (deliver in chat, do not commit): fixes grouped by dimension (structural / stale-status / code-reality / completeness), the list of new docs written, the contents of `.docs-audit/code-bug-flags.md` (code bugs flagged-not-fixed), and any deliberate completeness exclusions.
|
||||
|
||||
**Acceptance:** both gates green; change summary delivered.
|
||||
|
||||
---
|
||||
|
||||
## Execution order & parallelism summary
|
||||
|
||||
- **Phase 0:** Tasks 1 ∥ 2 (no deps).
|
||||
- **Phase 1:** after Phase 0, all accuracy tasks (3–7, 9–13, 15–18, 20–24) run in parallel — disjoint files. Each group's completeness task (8, 14, 19, 25) follows its group's accuracy tasks; the four completeness tasks are mutually parallel.
|
||||
- **Phase 2:** Task 26 after all completeness tasks; Task 27 after 26.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-03-documentation-audit.md",
|
||||
"designPath": "docs/plans/2026-06-03-documentation-audit-design.md",
|
||||
"branch": "docs/documentation-audit",
|
||||
"tasks": [
|
||||
{"id": 1, "nativeTaskId": 59, "subject": "Task 1: Structural checker script + initial run", "status": "pending", "blockedBy": []},
|
||||
{"id": 2, "nativeTaskId": 60, "subject": "Task 2: Code-first feature inventory + coverage diff", "status": "pending", "blockedBy": []},
|
||||
{"id": 3, "nativeTaskId": 61, "subject": "Task 3: OpcUaServer.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 4, "nativeTaskId": 62, "subject": "Task 4: AddressSpace.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 5, "nativeTaskId": 63, "subject": "Task 5: ReadWriteOperations.md + IncrementalSync.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 6, "nativeTaskId": 64, "subject": "Task 6: VirtualTags.md + ScriptedAlarms.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 7, "nativeTaskId": 65, "subject": "Task 7: AlarmTracking.md (orphan resolution)", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 8, "nativeTaskId": 66, "subject": "Task 8: G1 completeness & cross-links", "status": "pending", "blockedBy": [3, 4, 5, 6, 7]},
|
||||
{"id": 9, "nativeTaskId": 67, "subject": "Task 9: docs/drivers/README.md (index + capability matrix)", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 10, "nativeTaskId": 68, "subject": "Task 10: docs/drivers/Galaxy.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 11, "nativeTaskId": 69, "subject": "Task 11: FOCAS.md + FOCAS-Test-Fixture.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 12, "nativeTaskId": 70, "subject": "Task 12: Modbus + AbServer + AbLegacy test-fixture docs", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 13, "nativeTaskId": 71, "subject": "Task 13: S7 + TwinCAT + OpcUaClient test-fixture docs", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 14, "nativeTaskId": 72, "subject": "Task 14: G2 completeness & drivers index", "status": "pending", "blockedBy": [9, 10, 11, 12, 13]},
|
||||
{"id": 15, "nativeTaskId": 73, "subject": "Task 15: security.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 16, "nativeTaskId": 74, "subject": "Task 16: Redundancy.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 17, "nativeTaskId": 75, "subject": "Task 17: ServiceHosting.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 18, "nativeTaskId": 76, "subject": "Task 18: Reservations.md + StatusDashboard.md (stub)", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 19, "nativeTaskId": 77, "subject": "Task 19: G3 completeness & cross-links", "status": "pending", "blockedBy": [15, 16, 17, 18]},
|
||||
{"id": 20, "nativeTaskId": 78, "subject": "Task 20: Client.CLI.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 21, "nativeTaskId": 79, "subject": "Task 21: Client.UI.md", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 22, "nativeTaskId": 80, "subject": "Task 22: DriverClis.md (index + shared commands)", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 23, "nativeTaskId": 81, "subject": "Task 23: Driver.Modbus/AbCip/AbLegacy CLI docs", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 24, "nativeTaskId": 82, "subject": "Task 24: Driver.S7/TwinCAT/FOCAS CLI docs", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 25, "nativeTaskId": 83, "subject": "Task 25: G4 completeness & cross-links", "status": "pending", "blockedBy": [20, 21, 22, 23, 24]},
|
||||
{"id": 26, "nativeTaskId": 84, "subject": "Task 26: G5 reconciliation — README index + CLAUDE.md", "status": "pending", "blockedBy": [8, 14, 19, 25]},
|
||||
{"id": 27, "nativeTaskId": 85, "subject": "Task 27: Final gate + change summary", "status": "pending", "blockedBy": [26]}
|
||||
],
|
||||
"lastUpdated": "2026-06-03"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# Scope: Equipment-Namespace Materialization in the Live Deploy Path
|
||||
|
||||
**Status:** Scoping (not yet a task plan)
|
||||
**Date:** 2026-06-06
|
||||
**Author:** investigation while building the Northwind UNS overlay (see `scadaproj/otopcua-uns-loader/`)
|
||||
**Depends on:** the driver value-streaming fixes already on `master` (`c1ce583`, `b1b3f3f`)
|
||||
|
||||
---
|
||||
|
||||
## 1. One-paragraph summary
|
||||
|
||||
OtOpcUa can build a **SystemPlatform** namespace (the Galaxy mirror) into the live OPC UA
|
||||
address space with streaming values, but it **cannot do the same for an `Equipment`-kind
|
||||
namespace**. The canonical UNS (`Enterprise/Site/Area/Line/Equipment/Signal`) that an Equipment
|
||||
namespace represents only ever materialises its **skeleton** (Area/Line/Equipment *folders*); the
|
||||
**signals under equipment** (`Tag`, `VirtualTag`, `ScriptedAlarm` rows) never appear, because the
|
||||
component that turns those rows into OPC UA variables — `EquipmentNodeWalker` — is **fully built
|
||||
and unit-tested but never invoked in production**, and the live rebuild path doesn't carry the
|
||||
data it needs. This document scopes the work to finish that pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 2. What works vs. what doesn't (verified 2026-06-06)
|
||||
|
||||
**Works — SystemPlatform / Galaxy mirror (reference implementation):**
|
||||
A deploy materialises one folder per Galaxy object and one variable per `Tag` row, and the driver
|
||||
streams live values into them. Verified live: 396 tags across 40 machines, all `Good`, on
|
||||
`opc.tcp://localhost:4840`. Path:
|
||||
`OpcUaPublishActor.HandleRebuild` → `Phase7Applier.MaterialiseHierarchy` +
|
||||
`Phase7Applier.MaterialiseGalaxyTags`, and values via the `DriverHostActor` SubscribeBulk pass
|
||||
(`b1b3f3f`).
|
||||
|
||||
**Doesn't work — Equipment namespace:**
|
||||
Deploying an `Equipment` namespace + a `UnsArea`/`UnsLine`/`Equipment` + one `VirtualTag` produced:
|
||||
|
||||
```
|
||||
Phase7Applier: hierarchy materialised (areas=1, lines=1, equipment=1) ← folders only
|
||||
```
|
||||
|
||||
…and the equipment node had **zero child variables** — the VirtualTag never materialised. There
|
||||
was no equipment-tag/virtual-tag log line at all.
|
||||
|
||||
---
|
||||
|
||||
## 3. Root cause (precise)
|
||||
|
||||
Three gaps, in order of how fundamental they are:
|
||||
|
||||
### 3.1 `EquipmentNodeWalker` is built + tested but never wired (the core gap)
|
||||
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` — `Walk(IAddressSpaceBuilder, EquipmentNamespaceContent)`
|
||||
materialises every `Equipment` row as a folder and every Equipment-bound `Tag` / `VirtualTag` /
|
||||
`ScriptedAlarm` as a variable (NodeId = `DriverAttributeInfo.FullName`, i.e. the tag's
|
||||
`TagConfig.FullName`; VirtualTag uses its `VirtualTagId`).
|
||||
- **The only call sites are in `tests/Core/.../EquipmentNodeWalkerTests.cs`.** Nothing in `src`
|
||||
ever calls `EquipmentNodeWalker.Walk`, and nothing builds its input record
|
||||
`EquipmentNamespaceContent` (the record exists; no producer exists).
|
||||
- The live rebuild — `src/Server/.../Runtime/OpcUa/OpcUaPublishActor.cs:HandleRebuild` — calls
|
||||
`MaterialiseHierarchy` (the Area/Line/Equipment *folders*) and `MaterialiseGalaxyTags`
|
||||
(SystemPlatform only). It never calls `EquipmentNodeWalker`.
|
||||
- `OtOpcUaNodeManager` references `EquipmentNodeWalker` only in a header comment — not in
|
||||
`CreateAddressSpace`.
|
||||
|
||||
### 3.2 The deployment composition/artifact drops equipment signals
|
||||
|
||||
- `Phase7CompositionResult` (`src/Server/.../OpcUaServer/Phase7Composer.cs`) carries
|
||||
`UnsAreas`, `UnsLines`, `EquipmentNodes` *(EquipmentId, DisplayName, UnsLineId — no tags)*,
|
||||
`DriverInstancePlans`, `ScriptedAlarmPlans`, `GalaxyTags`. **There is no equipment-tag or
|
||||
virtual-tag list.**
|
||||
- `DeploymentArtifact.ParseComposition` (`src/Server/.../Runtime/Drivers/DeploymentArtifact.cs`)
|
||||
reads the artifact's `Tags` array but **`BuildGalaxyTagPlans` explicitly skips any tag with a
|
||||
non-null `EquipmentId`** (line ~176). So even though equipment tags are serialised into the
|
||||
artifact, the composition the node consumes throws them away.
|
||||
|
||||
So even if `EquipmentNodeWalker` were wired into `HandleRebuild`, it would have no equipment-tag
|
||||
data to walk.
|
||||
|
||||
### 3.3 No value source for Equipment-namespace signals
|
||||
|
||||
Equipment signals can be valued two ways; **neither is currently wired**:
|
||||
|
||||
- **Driver-sourced `Tag` (e.g. an OPC UA Client remap of the Galaxy mirror):** the **OpcUaClient
|
||||
driver has no factory registration** — `DriverFactoryBootstrap.Register`
|
||||
(`src/Server/.../Host/Drivers/DriverFactoryBootstrap.cs`) wires AbCip/AbLegacy/FOCAS/Galaxy/
|
||||
Modbus/S7/TwinCAT but **not OpcUaClient**, so an `OpcUaClient` `DriverInstance` silently stubs →
|
||||
no values. (The driver itself, `IDriver,ITagDiscovery,IReadable,IWritable,ISubscribable,…`,
|
||||
exists and is otherwise complete; only the factory `Register` is missing.)
|
||||
- **`VirtualTag` (script mirrors a live tag):** the `DependencyMuxActor` + a real
|
||||
`IVirtualTagEvaluator` are registered (`Runtime/ServiceCollectionExtensions.cs:100`,
|
||||
`Host/Program.cs:102`), and driver values now reach the mux (`DriverHostActor.ForwardToMux`,
|
||||
`b1b3f3f`). But `VirtualTagContext.GetTag` reads from a per-evaluation cache fed by an
|
||||
`ITagUpstreamSource`, and **no concrete `ITagUpstreamSource` is registered in the Host** — and
|
||||
it is unverified whether a `VirtualTag` in an `Equipment` namespace can resolve a tag that lives
|
||||
in the `SystemPlatform` namespace (cross-namespace `ctx.GetTag("/TestMachine_001/…")`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Goal / acceptance criteria
|
||||
|
||||
A deploy that includes an `Equipment`-kind namespace results in, on `opc.tcp://…:4840`:
|
||||
|
||||
1. **Structure:** browsable `…/<area>/<line>/<equipment>/<signal>` for every `UnsArea`/`UnsLine`/
|
||||
`Equipment`/`Tag`(+`VirtualTag`,`ScriptedAlarm`) row — folders **browse-named by their friendly
|
||||
`Name`**, not their logical Id (see §6.4).
|
||||
2. **Values:** each signal carries a live `Good` value (driver-sourced and/or VirtualTag-derived).
|
||||
3. **Reload-safe:** survives a node restart with no re-deploy (must run on the `RestoreApplied`
|
||||
bootstrap path added in `b1b3f3f`, not just on a fresh apply).
|
||||
4. **Verifiable headlessly:** the `scadaproj/otopcua-uns-loader` tool's `verify` passes against the
|
||||
company-shape namespace (extend it to browse the Equipment tree).
|
||||
|
||||
---
|
||||
|
||||
## 5. Workstreams
|
||||
|
||||
### WS-1 — Carry equipment signals through the composition (foundational)
|
||||
Extend `Phase7CompositionResult` with equipment `Tag`/`VirtualTag`/`ScriptedAlarm` plans (or reuse
|
||||
`EquipmentNamespaceContent`), populate them in `Phase7Composer.Compose`, serialise them in the
|
||||
deployment artifact, and parse them in `DeploymentArtifact.ParseComposition` (stop discarding
|
||||
`EquipmentId != null` tags). **Risk: medium** (touches composer + artifact format + planner; needs
|
||||
a format-compat story for already-sealed artifacts). **Effort: ~1–2 days.**
|
||||
|
||||
### WS-2 — Materialise equipment signals in the live rebuild (wire the existing component)
|
||||
In `OpcUaPublishActor.HandleRebuild`, after `MaterialiseHierarchy`, build `EquipmentNamespaceContent`
|
||||
from the composition and call the already-tested `EquipmentNodeWalker.Walk`. Make it idempotent and
|
||||
diff-aware to match the existing Galaxy-tag pass. **Risk: low–medium** (component is tested; this is
|
||||
wiring + an idempotency pass). **Effort: ~0.5–1 day.**
|
||||
|
||||
### WS-3 — Value path (pick one or both; see §6.1)
|
||||
- **3a VirtualTag route:** register a concrete `ITagUpstreamSource` in the Host that bridges the
|
||||
`DependencyMuxActor`'s tag values into the VirtualTag read-cache; confirm/enable cross-namespace
|
||||
`ctx.GetTag` resolution (Equipment VirtualTag reading a SystemPlatform mirror tag) and the
|
||||
dependency-graph re-evaluation trigger. **Risk: high** (cross-namespace resolution + dependency
|
||||
tracking are unproven end-to-end). **Effort: ~2–3 days.**
|
||||
- **3b OpcUaClient route:** write+register `OpcUaClientDriverFactoryExtensions.Register` and add it
|
||||
to `DriverFactoryBootstrap.Register`; extend the SubscribeBulk pass to also subscribe
|
||||
Equipment-namespace `Tag` refs (`TagConfig.FullName`; NodeId == FullName, so the existing
|
||||
`ForwardToMux` value routing already applies — a ~30-line generalisation prototyped and reverted
|
||||
this session); decide the self-referential endpoint topology (a MAIN driver node OPC-UA-clienting
|
||||
into its own `:4840` Galaxy mirror, vs a second cluster). **Risk: high** (unfinished driver +
|
||||
self-loop topology). **Effort: ~2–4 days.**
|
||||
|
||||
### WS-4 — Browse-name fix (cosmetic but required for a usable shape)
|
||||
Today the UNS folder browse name is the logical **Id** (observed `nw-area-filling`), not the
|
||||
friendly `Name` (`filling`). Confirm whether `Phase7Applier.MaterialiseHierarchy` /
|
||||
`EquipmentNode.DisplayName` should use `UnsArea.Name`/`UnsLine.Name`/`Equipment.Name` for the
|
||||
BrowseName (keeping the Id as the NodeId). **Risk: low.** **Effort: ~0.5 day.**
|
||||
|
||||
### WS-5 — Tests + headless verification
|
||||
Unit: composer carries equipment signals; `HandleRebuild` materialises them; round-trip artifact
|
||||
parse. Integration: a docker-dev deploy of a small Equipment namespace browses + reads `Good`.
|
||||
Extend `otopcua-uns-loader verify` to assert the Equipment tree. **Effort: ~1 day.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Design decisions / open questions
|
||||
|
||||
1. **VirtualTag (3a) vs OpcUaClient (3b) for live values.** VirtualTags reuse the live Galaxy
|
||||
mirror in-process (no second OPC UA session) but lean on unproven cross-namespace script
|
||||
resolution; OpcUaClient is the documented "remote-equipment → UNS" pattern but needs an
|
||||
unfinished driver factory and a self-referential session. **Recommendation:** prototype 3a first
|
||||
(smaller surface, no new driver), fall back to 3b if cross-namespace resolution proves
|
||||
intractable. A structure-only milestone (WS-1/2/4, no values) is independently shippable.
|
||||
2. **Cross-namespace `ctx.GetTag`.** Does an Equipment-namespace VirtualTag resolve a
|
||||
SystemPlatform Galaxy tag by browse path (`/TestMachine_001/TestChangingInt`) or by reference
|
||||
(`TestMachine_001.TestChangingInt`)? Determines the script-authoring contract. Must be settled
|
||||
before WS-3a.
|
||||
3. **Artifact format compatibility.** Adding equipment signals to the artifact changes its shape;
|
||||
ensure older sealed artifacts still parse (the parser is tolerant today — keep it so).
|
||||
4. **Browse-name source** (WS-4) — `Name` vs `Id`. Picking `Name` makes the company shape readable;
|
||||
confirm nothing keys off the Id-as-BrowseName.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommended sequencing
|
||||
|
||||
1. **WS-1 + WS-2 + WS-4 (structure-only):** Equipment namespaces browse the real
|
||||
`…/area/line/equipment/signal` shape with `BadWaitingForInitialData` leaves. Independently
|
||||
shippable; de-risks the composition/materialisation half.
|
||||
2. **WS-3a (VirtualTag values):** lights the structure up by mirroring the live Galaxy tags.
|
||||
3. **WS-3b (OpcUaClient driver):** only if a true remote-equipment driver path is wanted beyond the
|
||||
Galaxy mirror.
|
||||
4. **WS-5** throughout.
|
||||
|
||||
**Rough total:** structure-only ≈ 2–3.5 days; +VirtualTag values ≈ +2–3 days.
|
||||
|
||||
## 8. Out of scope
|
||||
- Authoring the company UNS rows (the `scadaproj/otopcua-uns-loader` tool already generates them
|
||||
from `company-uns.json`).
|
||||
- Any change to the SystemPlatform/Galaxy path, which works.
|
||||
- The AdminUI UNS editor.
|
||||
|
||||
## 9. Key references
|
||||
- Works (reference): `OpcUaPublishActor.HandleRebuild` + `Phase7Applier.MaterialiseGalaxyTags`;
|
||||
SubscribeBulk in `DriverHostActor` (commits `c1ce583`, `b1b3f3f`).
|
||||
- Built-but-unwired: `Core/OpcUa/EquipmentNodeWalker.cs` (+ `EquipmentNamespaceContent`),
|
||||
tested only by `EquipmentNodeWalkerTests.cs`.
|
||||
- Composition gap: `OpcUaServer/Phase7Composer.cs` (`Phase7CompositionResult`),
|
||||
`Runtime/Drivers/DeploymentArtifact.cs` (`BuildGalaxyTagPlans` skips `EquipmentId != null`).
|
||||
- Value gaps: `Host/Drivers/DriverFactoryBootstrap.cs` (no OpcUaClient registration);
|
||||
`Core.VirtualTags/ITagUpstreamSource.cs` (no Host registration found).
|
||||
- The consuming tool + the company model: `scadaproj/otopcua-uns-loader/`, `scadaproj/company-uns.json`.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Equipment-Namespace Structure Materialization — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** A deploy that includes an `Equipment`-kind namespace materialises its full
|
||||
`Area / Line / Equipment / Signal` browse tree into the live OPC UA address space on `:4840`,
|
||||
with friendly-`Name` browse names and `BadWaitingForInitialData` leaf values. (Live **values** are
|
||||
a separate later milestone and are explicitly out of scope here.)
|
||||
|
||||
**Architecture:** The live rebuild (`OpcUaPublishActor.HandleRebuild`) is **sink-based** — it drives
|
||||
`Phase7Applier` against an `IOpcUaAddressSpaceSink`, materialising the Area/Line/Equipment folder
|
||||
skeleton (`MaterialiseHierarchy`) and SystemPlatform/Galaxy variables (`MaterialiseGalaxyTags`).
|
||||
Today there is **no equipment-signal pass**: `Equipment`-bound `Tag`/`VirtualTag`/`ScriptedAlarm`
|
||||
rows never become variables. This plan adds that pass, mirroring `MaterialiseGalaxyTags`, fed by
|
||||
equipment data carried in the deployment composition. It also makes the UNS folders browse by their
|
||||
friendly `Name`.
|
||||
|
||||
**Tech Stack:** .NET 10, Akka.NET actors, EF Core (SQL Server), OPC UA SDK. Build/test from the repo
|
||||
root: `dotnet build`, `dotnet test`. Per-task tests live under `tests/Server/…` and `tests/Core/…`.
|
||||
|
||||
**Background (read first):** `docs/plans/2026-06-06-equipment-namespace-materialization-scope.md` —
|
||||
this plan implements its WS-1, WS-2, WS-4 (+ tests). The reference implementation to mirror is the
|
||||
Galaxy path: `Phase7Applier.MaterialiseGalaxyTags` + `OpcUaPublishActor.HandleRebuild`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture decisions (resolve before/while implementing)
|
||||
|
||||
These are surfaced from the investigation; Task 0 records the chosen answers in the plan/code.
|
||||
|
||||
1. **Reuse `EquipmentNodeWalker` vs add a sink pass.** `EquipmentNodeWalker.Walk` is fully built +
|
||||
unit-tested but writes to an `IAddressSpaceBuilder` (the driver-discovery API), whereas the
|
||||
rebuild path writes to `IOpcUaAddressSpaceSink`. Two ways to bridge:
|
||||
- **(A, recommended) Add `Phase7Applier.MaterialiseEquipmentTags(composition)`** — sink-based,
|
||||
a near-copy of `MaterialiseGalaxyTags`, iterating equipment tags and calling
|
||||
`_sink.EnsureFolder` / `_sink.EnsureVariable`. Consistent with the rest of the rebuild; no
|
||||
adapter. Downside: re-expresses some grouping logic the walker already has.
|
||||
- **(B) Adapt `EquipmentNodeWalker` via a sink-backed `IAddressSpaceBuilder`.** Check for an
|
||||
existing capturing builder (`GenericDriverNodeManager.CapturingBuilder`,
|
||||
`src/Core/…/Core/OpcUa/GenericDriverNodeManager.cs`); if one cleanly wraps the sink, call
|
||||
`EquipmentNodeWalker.Walk(capturingBuilder, content)` and reuse the tested logic. Downside:
|
||||
couples the rebuild to the driver-builder API + that adapter.
|
||||
**Recommendation:** spend the first 20 min of Task 2 confirming whether a sink→builder adapter
|
||||
exists and is cheap. If yes → B (reuse the tested walker). If not → A. This plan is written for
|
||||
**A** (lower coupling, self-contained); swap the Task 2 body for B if the adapter is clean.
|
||||
|
||||
2. **Where equipment data comes from at rebuild: artifact vs live DB.** `MaterialiseGalaxyTags` uses
|
||||
the sealed-artifact composition. For consistency and snapshot-correctness, carry equipment data
|
||||
in the composition too (Task 1). A pragmatic alternative with precedent (the `b1b3f3f` SubscribeBulk
|
||||
pass queries the live DB) is to load `EquipmentNamespaceContent` directly from the DB in the
|
||||
rebuild — simpler, but live-DB-vs-sealed-artifact can diverge. **This plan carries it in the
|
||||
composition (the correct, consistent choice).**
|
||||
|
||||
3. **Folder NodeId vs BrowseName.** Keep the existing scheme: **NodeId = logical Id**
|
||||
(`UnsAreaId`/`UnsLineId`/`EquipmentId`) so browse-path resolution + ACLs are unaffected; set the
|
||||
**BrowseName/DisplayName = friendly `Name`** (Task 3). `MaterialiseHierarchy` already keys NodeId
|
||||
on the Id and displays `DisplayName`; the bug is that `DisplayName` is currently populated with
|
||||
the Id. The fix is in the composer (Task 3), not the applier.
|
||||
|
||||
4. **No double-materialisation.** `MaterialiseHierarchy` already creates the Area/Line/Equipment
|
||||
folders. The new equipment-tag pass must only add the **variables** under existing equipment
|
||||
folders (and any per-tag `FolderPath` sub-folders) — it must NOT re-create the equipment folders.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Confirm signatures + record the architecture decisions
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (do first)
|
||||
|
||||
**Files:**
|
||||
- Read: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` (`MaterialiseGalaxyTags`, `MaterialiseHierarchy`, `SafeEnsureFolder`, the `_sink` API)
|
||||
- Read: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IOpcUaAddressSpaceSink.cs` (exact `EnsureFolder`/`EnsureVariable` signatures)
|
||||
- Read: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (`Phase7CompositionResult`, `Compose`, how `EquipmentNode.DisplayName` + galaxy tags are built)
|
||||
- Read: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`ParseComposition`, `BuildGalaxyTagPlans`)
|
||||
- Read: `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` (the tested logic to mirror: `AddTagVariable`, identifier properties)
|
||||
|
||||
**Step 1:** Decide A vs B (decision #1) — grep for `CapturingBuilder` / `IAddressSpaceBuilder`
|
||||
implementations that wrap `IOpcUaAddressSpaceSink`. If a clean adapter exists, note "Task 2 uses B".
|
||||
**Step 2:** Confirm the sink's `EnsureVariable` signature (NodeId, parent, displayName,
|
||||
`DriverAttributeInfo` incl. `FullName` + `DataType`) — `MaterialiseGalaxyTags` is the template.
|
||||
**Step 3:** Record the confirmed decisions as a comment block at the top of the new
|
||||
`MaterialiseEquipmentTags` (created in Task 2). No code/test change in this task.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Carry equipment signals in the deployment composition + artifact
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 2 depends on it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — add an `EquipmentTagPlan` list to `Phase7CompositionResult`; populate it in `Compose` from `Tag` rows where `EquipmentId != null` AND the tag's driver's namespace `Kind == Equipment` (the inverse of the galaxy filter). Set `DisplayName = Name` on Area/Line/Equipment records (decision #3 / Task 3 overlaps — do the field plumbing here).
|
||||
- Modify: the artifact serializer that writes `ArtifactBlob` (find via `grep -rn "ArtifactBlob\|RevisionHash\|Serialize" src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs`) — emit the equipment tags (with `EquipmentId`, `FolderPath`, `Name`, `DataType`, `DriverInstanceId`, `TagConfig.FullName`) into the `Tags` array (they are likely already there) and ensure Area/Line/Equipment friendly `Name`s are serialised.
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — add `BuildEquipmentTagPlans(root, drivers)`: the mirror of `BuildGalaxyTagPlans` that KEEPS `EquipmentId != null` tags whose namespace `Kind == Equipment`, reading `FullName` from `TagConfig`. Wire it into `ParseComposition`.
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs` (or the existing composition test file).
|
||||
|
||||
**Step 1 — failing test:** add a test that round-trips an artifact containing one Equipment
|
||||
namespace + one equipment `Tag` and asserts `ParseComposition(...).EquipmentTags` contains it with
|
||||
the right `EquipmentId`, `FullName`, `DataType`. Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter EquipmentTag` → FAIL (member missing).
|
||||
**Step 2 — implement** the `EquipmentTagPlan` record + populate in composer + parse in artifact.
|
||||
**Step 3 — run** the test → PASS, plus the full `Runtime.Tests` + `OpcUaServer.Tests` suites green.
|
||||
**Step 4 — commit:** `feat(opcua): carry Equipment-namespace tags through the deployment composition`.
|
||||
|
||||
**Design note:** `EquipmentNamespaceContent` (the `EquipmentNodeWalker` input) uses full entity
|
||||
types. If Task 2 chooses option B, `EquipmentTagPlan` should carry enough to reconstruct the
|
||||
`Tag`/`Equipment` fields the walker reads (`Name`, `FolderPath`, `EquipmentId`, `DataType`,
|
||||
`FullName`). For option A, a flat `EquipmentTagPlan(EquipmentId, DriverInstanceId, FolderPath, Name, DataType, FullName)` is enough.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Materialise equipment signals in the live rebuild
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (depends on Task 1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — add `MaterialiseEquipmentTags(Phase7CompositionResult composition)`, a sink-based near-copy of `MaterialiseGalaxyTags`: for each `EquipmentTagPlan`, ensure its `FolderPath` sub-folder (if any) **under the existing equipment folder** (`parentNodeId = EquipmentId` or the sub-folder), then `EnsureVariable(nodeId: FullName, parent, displayName: Name, attributeInfo: new DriverAttributeInfo(FullName, DataType, …))`. Log `equipment tags materialised (tags=N, equipment=M)`.
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:HandleRebuild` — after `MaterialiseGalaxyTags(composition)`, call `_applier.MaterialiseEquipmentTags(composition)`.
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs` (sibling to the existing hierarchy test, which mocks `IOpcUaAddressSpaceSink`).
|
||||
|
||||
**Step 1 — failing test:** with a fake sink, call `MaterialiseEquipmentTags` on a composition with
|
||||
one equipment tag and assert one `EnsureVariable(nodeId == FullName, parent == EquipmentId, displayName == Name)` call landed. Run filtered test → FAIL (method missing).
|
||||
**Step 2 — implement** `MaterialiseEquipmentTags` (mirror `MaterialiseGalaxyTags`; reuse
|
||||
`SafeEnsureFolder`; idempotent via the same dedupe the galaxy pass uses) **and** the
|
||||
`HandleRebuild` wire-up.
|
||||
**Step 3 — run** the new test + `OpcUaServer.Tests` + `Runtime.Tests` → PASS.
|
||||
**Step 4 — commit:** `feat(opcua): materialise Equipment-namespace tags in the live rebuild`.
|
||||
|
||||
**If Task 0 chose option B:** instead of a new method, build `EquipmentNamespaceContent` from the
|
||||
composition, obtain the sink-backed `IAddressSpaceBuilder`, and call `EquipmentNodeWalker.Walk`.
|
||||
Keep the same `HandleRebuild` call site + test assertions.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Friendly browse names for UNS folders
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (verify after Task 1, which plumbs `DisplayName`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — ensure the composition's
|
||||
Area/Line/Equipment records carry `DisplayName = <row>.Name` (not the logical Id). `MaterialiseHierarchy`
|
||||
already passes `DisplayName` to the sink as the folder browse name, so this is the only change needed.
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs` — assert
|
||||
`SafeEnsureFolder` is called with `displayName == "filling"` (Name) while `nodeId == "nw-area-filling"` (Id).
|
||||
|
||||
**Step 1 — failing test** asserting DisplayName == Name, NodeId == Id. Run → FAIL (currently DisplayName == Id).
|
||||
**Step 2 — implement** the composer change.
|
||||
**Step 3 — run** → PASS.
|
||||
**Step 4 — commit:** `fix(opcua): UNS folders browse by friendly Name, NodeId stays the logical Id`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Idempotency + restart-safety
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (after Task 2)
|
||||
|
||||
**Files:**
|
||||
- Read/verify: `OpcUaPublishActor.HandleRebuild` runs on both the apply path and the
|
||||
`DriverHostActor.RestoreApplied` bootstrap path (added in `b1b3f3f`) — so the new pass is already
|
||||
restart-covered. Confirm by inspection; no code change expected.
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs` — call
|
||||
`MaterialiseEquipmentTags` twice with the same composition and assert no duplicate `EnsureVariable`
|
||||
(idempotent), matching the galaxy pass's dedupe behaviour.
|
||||
|
||||
**Step 1 — failing test** (double-apply → single variable). **Step 2 — fix** dedupe if needed.
|
||||
**Step 3 — run** → PASS. **Step 4 — commit:** `test(opcua): equipment-tag materialisation is idempotent`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: docker-dev integration verification + tool support
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (last; needs Tasks 1–3 deployed)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs`
|
||||
(model on the existing `DriverReconnectE2eTests.cs` / phase-7 smoke) — seed a 1-area/1-line/1-equipment/1-tag
|
||||
Equipment namespace + a Modbus FK driver, apply a deployment, browse `…/filling/line-1/<eq>/<signal>`,
|
||||
assert the variable exists with `BadWaitingForInitialData` (structure-only).
|
||||
- Modify (in the `scadaproj` repo, not OtOpcUa): `scadaproj/otopcua-uns-loader/otopcua_uns.py` —
|
||||
add a `verify` branch that browses the Equipment tree (friendly names) and asserts the leaf count
|
||||
matches the loaded equipment tags. (Tracked here for completeness; commit in scadaproj.)
|
||||
|
||||
**Step 1 — write** the integration test (skip-guarded if it needs live infra, per the repo's other
|
||||
integration tests). **Step 2 — run** it against docker-dev (`docs/v2/implementation/phase-7-e2e-smoke.md`
|
||||
has the harness). **Step 3 — manual confirm** via the AdminUI Deploy at `:9200` + an asyncua browse.
|
||||
**Step 4 — commit:** `test(opcua): e2e Equipment-namespace structure materialisation`.
|
||||
|
||||
---
|
||||
|
||||
## Verification (whole milestone)
|
||||
|
||||
After all tasks: deploy an Equipment namespace via `scadaproj/otopcua-uns-loader` (extend it to emit
|
||||
Equipment rows) + the AdminUI Deploy, then browse `:4840`:
|
||||
- `OtOpcUa/filling/line-1/<equipment>/<signal>` exists, folders browse-named `filling` / `line-1` / …
|
||||
- leaf variables read `BadWaitingForInitialData` (values are the next milestone).
|
||||
- A node restart auto-restores the tree (via `RestoreApplied`) with no re-deploy.
|
||||
|
||||
## Out of scope (explicit)
|
||||
- **Live values** for equipment signals (driver subscribe / VirtualTag engine / OpcUaClient factory) —
|
||||
the next milestone (scope doc §5 WS-3).
|
||||
- The Galaxy/SystemPlatform path (works).
|
||||
- The AdminUI UNS editor.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-06-equipment-namespace-structure-milestone.md",
|
||||
"scopeDoc": "docs/plans/2026-06-06-equipment-namespace-materialization-scope.md",
|
||||
"branch": "feat/equipment-namespace-structure",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeTaskId": 86, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "completed", "blockedBy": []},
|
||||
{"id": 1, "nativeTaskId": 87, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "completed", "blockedBy": [86]},
|
||||
{"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "completed", "blockedBy": [87]},
|
||||
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "completed", "blockedBy": [87]},
|
||||
{"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "completed", "blockedBy": [88]},
|
||||
{"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "completed", "blockedBy": [88, 89]}
|
||||
],
|
||||
"lastUpdated": "2026-06-06"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Alarms D.1 — smoke artifact
|
||||
|
||||
> **Status (2026-05-29): alarm-source leg VERIFIED. Historian-write leg still
|
||||
> pending the Windows sidecar + live AVEVA Historian.**
|
||||
>
|
||||
> **Re-confirmed 2026-05-31** against the same gateway (`http://10.100.0.48:5120`):
|
||||
> the Skip-gated live test passed again, pulling a native `Raise` transition
|
||||
> (`Galaxy!TestArea.TestMachine_001.TestAlarm001`, raw sev 500 → OPC UA 750/High,
|
||||
> category `TestArea`, operator comment `Test alarm #1`) through the production
|
||||
> consumer. Independent re-run, not the original capture.
|
||||
>
|
||||
> This is the D.1 deliverable called for by `docs/plans/alarms-worker-wiring-plan.md`
|
||||
> — captured evidence that a live Galaxy alarm reaches lmxopcua through the native
|
||||
> gateway path (not the sub-attribute fallback). It supersedes the "A.2 blocked"
|
||||
> banners in `alarms-over-gateway.md` / `alarms-worker-wiring-plan.md`, which were
|
||||
> written 2026-04-30 before the gateway's alarm feed was working.
|
||||
|
||||
## What was verified
|
||||
|
||||
The mxaccessgw gateway **does** serve native MxAccess alarms today, and the lmxopcua
|
||||
consumer ingests them with full fidelity — **including operator-comment**, the field
|
||||
the 2026-04-30 plan flagged as "the only v1 regression."
|
||||
|
||||
Verified from the macOS dev box against the live gateway at `http://10.100.0.48:5120`
|
||||
(reachable; `nc -z` succeeds). No acknowledge / no writes were issued — read-only
|
||||
`StreamAlarms`.
|
||||
|
||||
### 1. Gateway boundary — raw `StreamAlarms` (`ZB.MOM.WW.MxGateway.Client`)
|
||||
|
||||
A standalone client streamed the active-alarm snapshot: **20 active alarms**, each
|
||||
carrying native metadata. Sample (one of 20):
|
||||
|
||||
```json
|
||||
{ "alarmFullReference": "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||
"sourceObjectReference": "TestMachine_001.TestAlarm001",
|
||||
"alarmTypeName": "DSC", "severity": 500,
|
||||
"currentState": "ALARM_CONDITION_STATE_ACTIVE", "category": "TestArea",
|
||||
"lastTransitionTimestamp": "2026-05-24T16:04:10.856Z",
|
||||
"operatorComment": "Test alarm #1" }
|
||||
```
|
||||
|
||||
Followed by the `SnapshotComplete` marker. `operatorComment`, `category`, `severity`,
|
||||
`currentState`, and `lastTransitionTimestamp` are all populated.
|
||||
|
||||
### 2. lmxopcua consumer — `GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition`
|
||||
|
||||
The Skip-gated live test
|
||||
`Runtime/GatewayGalaxyAlarmFeedLiveTests.Live_gateway_delivers_native_alarm_transitions_through_the_consumer`
|
||||
wires the real `MxGatewayClient.StreamAlarmsAsync` into the production consumer seam
|
||||
and **passes**. Captured output (`D1_SMOKE_OUT`):
|
||||
|
||||
```
|
||||
# consumer transitions observed: 2+
|
||||
Raise Galaxy!TestArea.TestMachine_001.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-24T16:04:10.856Z
|
||||
Raise Galaxy!TestArea.TestMachine_003.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-07T18:14:00.594Z
|
||||
```
|
||||
|
||||
The consumer preserves `operatorComment` + `category` + transition timestamp and
|
||||
applies the OPC UA severity-bucket mapping (`MxAccessSeverityMapper`: raw 500 →
|
||||
OPC UA 750, bucket `High`).
|
||||
|
||||
### 3. Full chain to the OPC UA Part 9 surface (code-path verified)
|
||||
|
||||
`GalaxyDriver.OnAlarmFeedTransition` maps `GalaxyAlarmTransition` →
|
||||
`AlarmEventArgs`, carrying `OperatorComment`, `OriginalRaiseTimestampUtc`,
|
||||
`AlarmCategory`, and the severity bucket onto `IAlarmSource.OnAlarmEvent`.
|
||||
`AlarmEventArgs` already declares those fields — so the **E.7 contract extension is
|
||||
done**, not pending. The server's Part-9 condition layer consumes `IAlarmSource`
|
||||
via `AlarmSurfaceInvoker` → `GenericDriverNodeManager`. Unit coverage:
|
||||
`GalaxyDriverAlarmSourceTests`, `GatewayGalaxyAlarmFeedTests`.
|
||||
|
||||
## How to re-run
|
||||
|
||||
```bash
|
||||
export MXGW_ENDPOINT="http://10.100.0.48:5120"
|
||||
export GALAXY_MXGW_API_KEY="<dev key from docker-dev/docker-compose.yml>"
|
||||
export D1_SMOKE_OUT="/tmp/d1-consumer-transitions.txt" # optional capture
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests \
|
||||
--filter "FullyQualifiedName~GatewayGalaxyAlarmFeedLiveTests"
|
||||
```
|
||||
|
||||
Without the env vars the test `Skip`s, so normal `dotnet test` runs are unaffected.
|
||||
|
||||
## Not covered here (still open)
|
||||
|
||||
1. **Scripted-alarm historian write-back → AVEVA Historian** (C.1's live leg). The
|
||||
`SdkAlarmHistorianWriteBackend` (real `HistorianAccess.AddStreamedValue` path) is
|
||||
implemented and unit-tested, but its `Live_*` write smoke needs the Windows
|
||||
historian sidecar + a live AVEVA Historian — neither reachable from the macOS dev
|
||||
box. Capture this leg on the Windows parity rig.
|
||||
2. **Running-server → OPC UA A&C client round-trip.** This artifact proves the driver
|
||||
consumer end; it does not exercise a full OtOpcUa server surfacing the condition to
|
||||
an OPC UA client, because the docker-dev stack stubs the Galaxy driver on Linux
|
||||
(`DriverInstanceActor.ShouldStub`). Capture on the Windows parity rig (or a Linux
|
||||
host with `ShouldStub` overridden to point the real driver at the gateway).
|
||||
|
||||
## Mechanism — true MxAccess alarm-event support
|
||||
|
||||
The gateway delivers these alarms via **true MxAccess alarm-event support** in the
|
||||
mxaccessgw .NET client — a real alarm-event subscription, **not** the value-driven
|
||||
sub-attribute fallback. (Confirmed by the gateway maintainer; the client-side stream
|
||||
check above can only observe the resulting feed, which is why this artifact records the
|
||||
mechanism here rather than inferring it.) So A.2 is implemented as originally specified:
|
||||
`MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries genuine native alarm-event metadata, and
|
||||
the operator-comment / original-raise-time / category fields are first-class — not
|
||||
reconstructed from attribute reads.
|
||||
@@ -9,24 +9,41 @@
|
||||
> the new RPCs; the sub-attribute fallback path keeps Galaxy alarms
|
||||
> functional today.
|
||||
>
|
||||
> ⚠️ **Worker-side native alarm subscription blocked on a dev-rig
|
||||
> finding (2026-04-30):** the MXAccess COM Toolkit at
|
||||
> ✅ **UPDATE 2026-05-29 — native alarm feed VERIFIED working; the
|
||||
> 2026-04-30 "blocked" finding below is superseded.** A live
|
||||
> `StreamAlarms` check against the gateway at `10.100.0.48:5120`
|
||||
> returned the active-alarm snapshot (20 alarms) with full native
|
||||
> metadata — `severity`, `category`, `currentState`,
|
||||
> `lastTransitionTimestamp`, **and `operatorComment`** (the field the
|
||||
> note below called "the only v1 regression"). The lmxopcua consumer
|
||||
> (`GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition` →
|
||||
> `AlarmEventArgs` → `IAlarmSource`) ingests it with full fidelity and
|
||||
> the OPC UA severity-bucket mapping applied — proven by the passing
|
||||
> Skip-gated live test `GatewayGalaxyAlarmFeedLiveTests`. `AlarmEventArgs`
|
||||
> already carries operator-comment / original-raise-time / category, so
|
||||
> **E.7 is done too**. See `docs/plans/alarms-d1-smoke-artifact.md` for
|
||||
> the captured evidence. The gateway delivers this via **true MxAccess
|
||||
> alarm-event support** in the mxaccessgw .NET client (a real
|
||||
> alarm-event subscription — **not** the sub-attribute fallback), so A.2
|
||||
> is implemented as originally specified. Still open: the scripted-alarm
|
||||
> → AVEVA Historian write-back live smoke (C.1's `Live_*` leg) and a full
|
||||
> running-server → OPC UA A&C round-trip — both need the Windows parity rig.
|
||||
>
|
||||
> ⚠️ **[SUPERSEDED — kept for history] Worker-side native alarm
|
||||
> subscription blocked on a dev-rig finding (2026-04-30):** the MXAccess
|
||||
> COM Toolkit at
|
||||
> `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
|
||||
> exposes no alarm-event family — only `OnDataChange`,
|
||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange`.
|
||||
> exposed no alarm-event family — only `OnDataChange`,
|
||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange` — and
|
||||
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
|
||||
> assemblies are x64-only and incompatible with the worker's x86
|
||||
> bitness. **Operator decision needed before
|
||||
> `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries any events:** either
|
||||
> accept the value-driven sub-attribute path as the production
|
||||
> architecture (operator-comment fidelity is the only v1 regression)
|
||||
> or add an x64 alarm-helper sub-process alongside the worker. See
|
||||
> `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` in the
|
||||
> mxaccessgw repo for the architectural notes. Live
|
||||
> `aahClientManaged` alarm-event write call site
|
||||
> (`SdkAlarmHistorianWriteBackend` placeholder from PR C.1) and the
|
||||
> D.1 smoke artifact ship once those decisions resolve. The
|
||||
> remainder of this document is preserved as the design record.
|
||||
> assemblies are x64-only vs. the worker's x86 bitness. The operator
|
||||
> decision (accept the value-driven sub-attribute path, or add an x64
|
||||
> alarm-helper sub-process) has since been resolved on the gateway side
|
||||
> — `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` now carries events (verified
|
||||
> above). The C.1 `SdkAlarmHistorianWriteBackend` is **no longer a
|
||||
> placeholder** — it writes through the real
|
||||
> `HistorianAccess.AddStreamedValue` path (only its live-rig write
|
||||
> smoke remains).
|
||||
|
||||
Coordinated epic across two repos:
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Alarms Worker Wiring Plan
|
||||
|
||||
> ✅ **UPDATE 2026-05-29 — the blocker below is RESOLVED on the gateway side; this
|
||||
> plan is largely complete.** A live `StreamAlarms` check against `10.100.0.48:5120`
|
||||
> returns the active-alarm snapshot with full native metadata **including
|
||||
> `operatorComment`**, and the lmxopcua consumer ingests it end-to-end (passing live
|
||||
> test `GatewayGalaxyAlarmFeedLiveTests`). So **A.2 / A.3 / A.4** are functionally done
|
||||
> at the gateway boundary (the worker now emits native alarm transitions and the client
|
||||
> exposes `AcknowledgeAlarm` / `QueryActiveAlarms` RPCs). **C.1** ships real code
|
||||
> (`SdkAlarmHistorianWriteBackend` → `HistorianAccess.AddStreamedValue`). **D.1**'s
|
||||
> alarm-source leg is captured in `docs/plans/alarms-d1-smoke-artifact.md`. Only two
|
||||
> things remain, both needing the Windows parity rig: C.1's live historian-write smoke
|
||||
> and a full running-server → OPC UA A&C round-trip. The per-item detail below is kept
|
||||
> as the historical record of the original blocked state.
|
||||
>
|
||||
> **Context**: The alarms-over-gateway epic shipped 19 PRs across the
|
||||
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
|
||||
> the sub-attribute fallback path keeps Galaxy alarms functional today. Four
|
||||
@@ -16,7 +29,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Dev-rig finding that blocks everything (2026-04-30)
|
||||
## Dev-rig finding that blocks everything (2026-04-30) — [SUPERSEDED 2026-05-29]
|
||||
|
||||
During PR A.2 work the following was discovered on the dev box:
|
||||
|
||||
@@ -318,16 +331,20 @@ fallback as production).
|
||||
|
||||
## Summary of blocks
|
||||
|
||||
| Item | Blocked by | Estimated effort once unblocked |
|
||||
|------|-----------|--------------------------------|
|
||||
| A.2 | Architectural decision (x64 alarm-helper vs. sub-attribute fallback as production) | 2–3 days implementation; 1 day tests |
|
||||
| A.3 | A.2 delivering WorkerEvent bodies | 1–2 days |
|
||||
| A.4 | A.2 (active-alarm query needs AlarmClient session) | 1 day |
|
||||
| C.1 | aahClientManaged SDK access (available on dev box); NOT blocked by A.2 | 1–2 days |
|
||||
| D.1 | A.2 + A.3 + C.1 all passing on parity rig | 0.5 day (smoke + artifact capture) |
|
||||
> **Resolved as of 2026-05-29** — see the update banner at the top and
|
||||
> `docs/plans/alarms-d1-smoke-artifact.md`. Original status table kept for history.
|
||||
|
||||
C.1 can proceed in parallel with A.2 / A.3 since the sidecar's `aahClientManaged`
|
||||
is x64 and does not share the worker bitness constraint.
|
||||
| Item | Status (2026-05-29) | Original block |
|
||||
|------|--------------------|----------------|
|
||||
| A.2 | ✅ **True MxAccess alarm-event support** in the gateway client (real alarm-event subscription, not the sub-attribute fallback); verified via live `StreamAlarms` with operator-comment fidelity | Architectural decision (x64 alarm-helper vs. sub-attribute fallback) |
|
||||
| A.3 | ✅ Dispatch + `AcknowledgeAlarm` RPC present on the client surface | A.2 delivering WorkerEvent bodies |
|
||||
| A.4 | ✅ `QueryActiveAlarms` RPC present on the client surface | A.2 (active-alarm query needs AlarmClient session) |
|
||||
| C.1 | ✅ Code shipped (`AddStreamedValue` path); ⏳ live historian-write smoke needs the Windows rig | aahClientManaged SDK access |
|
||||
| D.1 | ◑ Alarm-source leg captured (`alarms-d1-smoke-artifact.md`); ⏳ historian-write leg + full server→A&C round-trip need the Windows rig | A.2 + A.3 + C.1 all passing on parity rig |
|
||||
|
||||
The gateway delivers operator-comment fidelity through **true MxAccess alarm-event
|
||||
support** in the mxaccessgw .NET client — a real alarm-event subscription, not the
|
||||
value-driven sub-attribute path. The sub-attribute fallback is now legacy.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+106
-94
@@ -4,12 +4,15 @@
|
||||
> Paths + project names moved: `OtOpcUa.Server/Security/` → `OtOpcUa.Security/`
|
||||
> (`Ldap/`, `Jwt/`, `Endpoints/AuthEndpoints.cs`), `OtOpcUa.Admin` is gone (its
|
||||
> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies
|
||||
> register in `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth` rather than in a
|
||||
> separate Admin process. The v2 `Security:Jwt` section adds JWT bearer auth
|
||||
> alongside the existing cookie scheme (`AddJwtBearer` wired via
|
||||
> `IPostConfigureOptions<JwtBearerOptions>` in `OtOpcUa.Security`). DataProtection
|
||||
> keys persist to the shared `ConfigDb.DataProtectionKeys` table so cookies
|
||||
> survive failover between admin-role nodes.
|
||||
> register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth`
|
||||
> (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather
|
||||
> than in a separate Admin process. The Admin UI uses a **single Cookie
|
||||
> authentication scheme** — there is no `AddJwtBearer` pipeline. The
|
||||
> `Security:Jwt` section configures `JwtTokenService`, which mints a JWT at the
|
||||
> `/auth/token` endpoint for **external** consumers (OPC UA clients / automation
|
||||
> scripts); the cookie itself stores the `ClaimsPrincipal` directly. DataProtection
|
||||
> keys persist to the shared Config DB (`PersistKeysToDbContext<OtOpcUaConfigDbContext>`)
|
||||
> so cookies survive failover between admin-role nodes.
|
||||
>
|
||||
> See `docs/plans/2026-05-26-akka-hosting-alignment-design.md` §5 for the v2
|
||||
> auth + DataProtection rationale.
|
||||
@@ -18,8 +21,8 @@ OtOpcUa has four independent security concerns. This document covers all four:
|
||||
|
||||
1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust).
|
||||
2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind.
|
||||
3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `PermissionTrie` against the Config DB `NodeAcl` tree.
|
||||
4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`ConfigViewer` / `ConfigEditor` / `FleetAdmin`) claim from `LdapGroupRoleMapping`.
|
||||
3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `TriePermissionEvaluator` over a `PermissionTrie` built from the Config DB `NodeAcl` tree.
|
||||
4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`.
|
||||
|
||||
Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB.
|
||||
|
||||
@@ -33,42 +36,43 @@ The OtOpcUa Server supports configurable OPC UA transport security profiles that
|
||||
|
||||
There are two distinct layers of security in OPC UA:
|
||||
|
||||
- **Transport security** -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the `OpcUaServer:SecurityProfile` setting controls.
|
||||
- **Transport security** -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the `OpcUa:EnabledSecurityProfiles` setting controls.
|
||||
- **UserName token encryption** -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. UserName authentication therefore works on `None` endpoints too — the credentials themselves are always encrypted. A secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.
|
||||
|
||||
### Supported security profiles
|
||||
|
||||
The server supports seven transport security profiles:
|
||||
The profiles are the members of the `OpcUaSecurityProfile` enum (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`). The server ships **three** baseline profiles; the config value is the bare enum-member name (no hyphens, no underscores):
|
||||
|
||||
| Profile Name | Security Policy | Message Security Mode | Description |
|
||||
|-----------------------------------|----------------------------|-----------------------|--------------------------------------------------|
|
||||
| `None` | None | None | No signing or encryption. Suitable for development and isolated networks only. |
|
||||
| `Basic256Sha256-Sign` | Basic256Sha256 | Sign | Messages are signed but not encrypted. Protects against tampering but data is visible on the wire. |
|
||||
| `Basic256Sha256-SignAndEncrypt` | Basic256Sha256 | SignAndEncrypt | Messages are both signed and encrypted. Full protection against tampering and eavesdropping. |
|
||||
| `Aes128_Sha256_RsaOaep-Sign` | Aes128_Sha256_RsaOaep | Sign | Modern profile with AES-128 encryption and SHA-256 signing. |
|
||||
| `Aes128_Sha256_RsaOaep-SignAndEncrypt` | Aes128_Sha256_RsaOaep | SignAndEncrypt | Modern profile with AES-128 encryption. Recommended for production. |
|
||||
| `Aes256_Sha256_RsaPss-Sign` | Aes256_Sha256_RsaPss | Sign | Strongest profile with AES-256 and RSA-PSS signatures. |
|
||||
| `Aes256_Sha256_RsaPss-SignAndEncrypt` | Aes256_Sha256_RsaPss | SignAndEncrypt | Strongest profile. Recommended for high-security deployments. |
|
||||
| Enum member | Security Policy | Message Security Mode | Description |
|
||||
|---------------------------------|------------------|-----------------------|--------------------------------------------------|
|
||||
| `None` | None | None | No signing or encryption. Suitable for development and isolated networks only. |
|
||||
| `Basic256Sha256Sign` | Basic256Sha256 | Sign | Messages are signed but not encrypted. Protects against tampering but data is visible on the wire. |
|
||||
| `Basic256Sha256SignAndEncrypt` | Basic256Sha256 | SignAndEncrypt | Messages are both signed and encrypted. Full protection against tampering and eavesdropping. |
|
||||
|
||||
The server exposes a separate endpoint for each configured profile, and clients select the one they prefer during connection.
|
||||
`BuildSecurityPolicies` (`OpcUaApplicationHost.cs`) maps each configured profile to an SDK `ServerSecurityPolicy`. The server exposes a separate endpoint per configured profile and clients select the one they prefer at session open. The enum's XML doc notes that Aes128/Aes256 variants can be added later by extending the enum + `BuildSecurityPolicies` — the wiring is profile-agnostic — but they are **not implemented today**. There is no `SecurityProfileResolver` class.
|
||||
|
||||
> **Config value form.** The enum binds by member name, so a profile string with hyphens (e.g. `Basic256Sha256-Sign`) does **not** bind — use the exact enum-member spelling above. If `EnabledSecurityProfiles` is empty, the server falls back to a single `None` endpoint (logged, very visible) so it still has a listening endpoint.
|
||||
|
||||
### Configuration
|
||||
|
||||
Transport security is configured in the `OpcUaServer` section of the Server process's bootstrap `appsettings.json`:
|
||||
Transport security is configured in the `OpcUa` section of the Host process's bootstrap `appsettings.json` (bound to `OpcUaApplicationHostOptions`):
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUaServer": {
|
||||
"EndpointUrl": "opc.tcp://0.0.0.0:4840/OtOpcUa",
|
||||
"ApplicationName": "OtOpcUa Server",
|
||||
"OpcUa": {
|
||||
"ApplicationName": "OtOpcUa",
|
||||
"ApplicationUri": "urn:node-a:OtOpcUa",
|
||||
"PublicHostname": "0.0.0.0",
|
||||
"OpcUaPort": 4840,
|
||||
"PkiStoreRoot": "C:/ProgramData/OtOpcUa/pki",
|
||||
"AutoAcceptUntrustedClientCertificates": false,
|
||||
"SecurityProfile": "Basic256Sha256-SignAndEncrypt"
|
||||
"EnabledSecurityProfiles": [ "Basic256Sha256Sign", "Basic256Sha256SignAndEncrypt" ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`EnabledSecurityProfiles` is a **list** — the server publishes one endpoint per entry. The default (when the key is omitted) is all three baseline profiles (`None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`); production deployments typically drop `None`. The list must contain at least one entry (`OpcUaApplicationHostOptionsValidator` enforces `MinCount(…, 1)`).
|
||||
|
||||
The server certificate is auto-generated on first start if none exists in `PkiStoreRoot/own/`. Always generated even for `None`-only deployments because UserName token encryption depends on it.
|
||||
|
||||
### PKI directory layout
|
||||
@@ -91,13 +95,13 @@ When a client connects using a secure profile (`Sign` or `SignAndEncrypt`), the
|
||||
4. If not found and `AutoAcceptUntrustedClientCertificates` is `true`, the certificate is automatically copied to `trusted/` and the connection proceeds.
|
||||
5. If not found and `AutoAcceptUntrustedClientCertificates` is `false`, the certificate is copied to `rejected/` and the connection is refused.
|
||||
|
||||
The Admin UI `Certificates.razor` page uses `CertTrustService` (singleton reading `CertTrustOptions` for the Server's `PkiStoreRoot`) to promote rejected client certs to trusted without operators having to file-copy manually.
|
||||
The Admin UI `Certificates.razor` page (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor`) lists the contents of each PKI sub-store (own / trusted / issuer / rejected) by reading the `OpcUa:PkiStoreRoot` path from configuration. It is currently a **read-only viewer** — promoting a rejected cert to trusted is still a file move (copy the `.der` from `rejected/` to `trusted/certs/`); the SDK trust list reloads on the next handshake.
|
||||
|
||||
### Production hardening
|
||||
|
||||
- Set `AutoAcceptUntrustedClientCertificates = false`.
|
||||
- Drop `None` from the profile set.
|
||||
- Use the Admin UI to promote trusted client certs rather than the auto-accept fallback.
|
||||
- Drop `None` from `EnabledSecurityProfiles`.
|
||||
- Promote trusted client certs by moving the `.der` from `rejected/` to `trusted/certs/` rather than relying on the auto-accept fallback. (The Admin UI Certificates page shows what is in each store.)
|
||||
- Periodically audit the `rejected/` directory; an unexpected entry is often a misconfigured client or a probe attempt.
|
||||
|
||||
---
|
||||
@@ -108,59 +112,55 @@ The Server accepts three OPC UA identity-token types:
|
||||
|
||||
| Token | Handler | Notes |
|
||||
|---|---|---|
|
||||
| Anonymous | `IUserAuthenticator.AuthenticateAsync(username: "", password: "")` | Refused in strict mode unless explicit anonymous grants exist; allowed in lax mode for backward compatibility. |
|
||||
| UserName/Password | `LdapUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) | LDAP bind + group lookup; resolved `LdapGroups` flow into the session's identity bearer (`ILdapGroupsBearer`). |
|
||||
| X.509 Certificate | Stack-level acceptance + role mapping via CN | X.509 identity carries `AuthenticatedUser` + read roles; finer-grain authorization happens through the data-plane ACLs. |
|
||||
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | Data-plane authorization (below) still default-denies any node a session has no ACL grant for. |
|
||||
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. |
|
||||
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow); finer-grain authorization happens through the data-plane ACLs. |
|
||||
|
||||
### LDAP bind flow (`LdapUserAuthenticator`)
|
||||
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`.
|
||||
|
||||
`Program.cs` in the Server registers the authenticator based on `OpcUaServer:Ldap`:
|
||||
### LDAP bind flow (`OtOpcUaLdapAuthService`)
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
|
||||
? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService<ILogger<LdapUserAuthenticator>>())
|
||||
: new DenyAllUserAuthenticator());
|
||||
```
|
||||
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. The same `ILdapAuthService` instance serves **both** the Admin UI cookie login (`/auth/login`) and the OPC UA UserName path (via `LdapOpcUaUserAuthenticator`), so operators use one credential across both planes.
|
||||
|
||||
`LdapUserAuthenticator`:
|
||||
`OtOpcUaLdapAuthService.AuthenticateAsync`:
|
||||
|
||||
1. Refuses to bind over plain-LDAP unless `AllowInsecureLdap = true` (dev/test only).
|
||||
2. Connects to `Server:Port`, optionally upgrades to TLS (`UseTls = true`, port 636 for AD).
|
||||
3. Binds as the service account; searches `SearchBase` for `UserNameAttribute = username`.
|
||||
4. Rebinds as the resolved user DN with the supplied password (the actual credential check).
|
||||
5. Reads `GroupAttribute` (default `memberOf`) and strips the leading `CN=` so operators configure friendly group names in `GroupToRole`.
|
||||
6. Returns a `UserAuthResult` carrying the validated username + the set of LDAP groups. The set flows through to the session identity via `ILdapGroupsBearer.LdapGroups`.
|
||||
1. If `Enabled = false`, denies outright — no bind, no DevStub bypass (the master switch wins).
|
||||
2. If `DevStubMode = true`, accepts any non-empty credentials and grants the `Administrator` role **without any network bind** (dev only — must be `false` in production).
|
||||
3. Refuses to bind over a plaintext transport (`Transport = None`) unless `AllowInsecure = true` (dev/test only). This is enforced at login, not at startup.
|
||||
4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships.
|
||||
5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password".
|
||||
|
||||
Configuration example (Active Directory production):
|
||||
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity for the ACL evaluator. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
|
||||
|
||||
`Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production):
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUaServer": {
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"DevStubMode": false,
|
||||
"Server": "dc01.corp.example.com",
|
||||
"Port": 636,
|
||||
"UseTls": true,
|
||||
"AllowInsecureLdap": false,
|
||||
"Transport": "Ldaps",
|
||||
"AllowInsecure": false,
|
||||
"SearchBase": "DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountDn": "CN=OtOpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountPassword": "<from your secret store>",
|
||||
"GroupAttribute": "memberOf",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"UserNameAttribute": "sAMAccountName",
|
||||
"GroupToRole": {
|
||||
"OPCUA-Operators": "WriteOperate",
|
||||
"OPCUA-Engineers": "WriteConfigure",
|
||||
"OPCUA-Tuners": "WriteTune",
|
||||
"OPCUA-AlarmAck": "AlarmAck"
|
||||
"OPCUA-Designers": "Designer",
|
||||
"OPCUA-Admins": "Administrator",
|
||||
"OPCUA-Operators": "Operator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form. Nested group membership is not expanded — assign users directly to the role-mapped groups, or pre-flatten in AD.
|
||||
|
||||
The same options bind the Admin's `LdapAuthService` (cookie auth / login form) so operators authenticate with a single credential across both processes.
|
||||
`GroupToRole` maps LDAP group names → Admin roles (case-insensitive); a user gets every role whose source group is in their membership. The values are the canonical control-plane role strings (`Viewer` / `Designer` / `Administrator`, plus the appsettings-only `Operator` for the `DriverOperator` policy). `UserNameAttribute: "sAMAccountName"` is the critical AD override — the GLAuth dev default is `cn`, which is not how AD users are looked up; use `userPrincipalName` instead if operators log in with `user@corp.example.com` form. `LdapOptionsValidator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`) fails startup when `Transport = None` and `AllowInsecure = false` on a real-LDAP (non-DevStub) config.
|
||||
|
||||
---
|
||||
|
||||
@@ -172,20 +172,27 @@ Per decision #129 the model is **additive-only — no explicit Deny**. Grants at
|
||||
|
||||
### Hierarchy
|
||||
|
||||
ACLs are evaluated against the UNS path:
|
||||
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
|
||||
|
||||
```
|
||||
ClusterId → Namespace → UnsArea → UnsLine → Equipment → Tag
|
||||
Equipment (UNS) kind: Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag
|
||||
SystemPlatform (Galaxy) kind: Cluster → Namespace → FolderSegment(s) → Tag
|
||||
```
|
||||
|
||||
On the Galaxy/SystemPlatform path each folder segment takes one trie level, so a deeply-nested Galaxy folder reaches the same depth as a full UNS path. Unset mid-path levels leave the corresponding id `null` and the evaluator walks only as far as the scope goes.
|
||||
|
||||
Each level can carry `NodeAcl` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs`) that grant a permission bundle to a set of `LdapGroups`.
|
||||
|
||||
### Permission flags
|
||||
|
||||
`NodePermissions` (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs`), stored as an `int` bitmask in `NodeAcl.PermissionFlags`:
|
||||
|
||||
```csharp
|
||||
[Flags]
|
||||
public enum NodePermissions : uint
|
||||
public enum NodePermissions : int
|
||||
{
|
||||
None = 0,
|
||||
|
||||
Browse = 1 << 0,
|
||||
Read = 1 << 1,
|
||||
Subscribe = 1 << 2,
|
||||
@@ -215,26 +222,22 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces
|
||||
| Class | Role |
|
||||
|---|---|
|
||||
| `PermissionTrie` | Cluster-scoped trie; each node carries `(GroupId → NodePermissions)` grants. |
|
||||
| `PermissionTrieBuilder` | Builds a trie from the current `NodeAcl` rows in one pass. |
|
||||
| `PermissionTrieCache` | Per-cluster memoised trie; invalidated via `AclChangeNotifier` when the Admin publishes a draft that touches ACLs. |
|
||||
| `TriePermissionEvaluator` | Implements `IPermissionEvaluator.Authorize(session, operation, scope)` — walks from the root to the leaf for the supplied `NodeScope`, unions grants along the path, compares required permission to the union. |
|
||||
| `PermissionTrieBuilder` | Builds a trie from the current `NodeAcl` rows in one pass and installs it into the cache. |
|
||||
| `PermissionTrieCache` | Process-singleton cache keyed on `(ClusterId, GenerationId)`. Generation-sealed: `Install(trie)` adds a new generation + advances the "current" pointer; older generations are retained (in-flight requests still resolve) and GC'd by `Prune`. `Invalidate(clusterId)` drops every cached trie for a cluster. There is **no** `AclChangeNotifier` — a publish installs a new generation rather than signalling an invalidation. |
|
||||
| `TriePermissionEvaluator` | Implements `IPermissionEvaluator.Authorize(session, operation, scope)`. Walks the cluster trie for the supplied `NodeScope`, unions grants along the path, and returns an `AuthorizationDecision`. Evaluates against the **session's bound generation** (`session.AuthGenerationId`), not just "current", so a grant added/removed in a newer generation cannot take effect mid-session. |
|
||||
|
||||
`NodeScope` carries `(ClusterId, NamespaceId, AreaId, LineId, EquipmentId, TagId)`; any suffix may be null — a tag-level ACL is more specific than an area-level ACL but both contribute via union.
|
||||
`NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute.
|
||||
|
||||
### Dispatch gate — `AuthorizationGate`
|
||||
### Dispatch gate — `IPermissionEvaluator`
|
||||
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs` bridges the OPC UA stack's `ISystemContext.UserIdentity` to the evaluator. `DriverNodeManager` holds exactly one reference to it and calls `IsAllowed(identity, OpcUaOperation.*, NodeScope)` on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call path. A false return short-circuits the dispatch with `BadUserAccessDenied`.
|
||||
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call; a `NotGranted` decision denies the operation.
|
||||
|
||||
Key properties:
|
||||
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through `AuthorizationGate`.
|
||||
- **Fail-open-during-transition.** `StrictMode = false` (default during ACL rollouts) lets sessions without resolved LDAP groups proceed; flip `Authorization:StrictMode = true` in production once ACLs are populated.
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator.
|
||||
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. There is no `StrictMode` / fail-open mode; absence of a grant is always a deny.
|
||||
- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit.
|
||||
|
||||
### Probe-this-permission (Admin UI)
|
||||
|
||||
`PermissionProbeService` (`src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/PermissionProbeService.cs`) lets an operator ask "if a user with groups X, Y, Z asked to do operation O on node N, would it succeed?" The answer is rendered in the AclsTab "Probe" dialog — same evaluator, same trie, so the Admin UI answer and the live Server answer cannot disagree.
|
||||
|
||||
### Full model
|
||||
|
||||
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny).
|
||||
@@ -245,31 +248,40 @@ See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie in
|
||||
|
||||
Control-plane authorization governs **the Admin UI** — who can view fleet config, edit drafts, publish generations, manage cluster nodes + credentials.
|
||||
|
||||
Per decision #150 control-plane roles are **deliberately independent of data-plane ACLs**. An operator who can read every OPC UA tag in production may not be allowed to edit cluster config; conversely a ConfigEditor may not have any data-plane grants at all.
|
||||
Per decision #150 control-plane roles are **deliberately independent of data-plane ACLs**. An operator who can read every OPC UA tag in production may not be allowed to edit cluster config; conversely a `Designer` may not have any data-plane grants at all.
|
||||
|
||||
### Roles
|
||||
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs`:
|
||||
The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs`) defines three roles. Task 1.7 standardized the member names on the canonical `ZB.MOM.WW.Auth` `CanonicalRole` vocabulary (`ConfigViewer → Viewer`, `ConfigEditor → Designer`, `FleetAdmin → Administrator`); a data migration (`CanonicalizeAdminRoles`) rewrote existing rows. This was a rename, not a permission change.
|
||||
|
||||
| Role | Capabilities |
|
||||
|---|---|
|
||||
| `ConfigViewer` | Read-only access to drafts, generations, audit log, fleet status. |
|
||||
| `ConfigEditor` | ConfigViewer plus draft editing (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. |
|
||||
| `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. |
|
||||
| `Viewer` | Read-only access to drafts, generations, audit log, fleet status. (Was `ConfigViewer`.) |
|
||||
| `Designer` | Viewer plus draft authoring (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. (Was `ConfigEditor`.) |
|
||||
| `Administrator` | Designer plus publish, cluster/node CRUD, credential management, role-grant management. Satisfies both the `FleetAdmin` and `DriverOperator` authorization policies. (Was `FleetAdmin`.) |
|
||||
|
||||
Policies registered in Admin `Program.cs`:
|
||||
`DriverOperator` is an **authorization policy name** (kept stable), not an `AdminRole` member. It gates **Reconnect** / **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel` and requires the canonical role `Operator` or `Administrator` (`policy.RequireRole("Operator", "Administrator")` in `AddAuthorization`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). `Operator` is an appsettings-only string role (not an `AdminRole` member); map an LDAP group to it via `GroupToRole`, e.g. `"ot-driver-operator": "Operator"`. The `FleetAdmin` policy requires the `Administrator` role.
|
||||
|
||||
```csharp
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin));
|
||||
```
|
||||
|
||||
Razor pages and API endpoints gate with `[Authorize(Policy = "CanEdit")]` / `"CanPublish"`; nav-menu sections hide via `<AuthorizeView>`.
|
||||
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`), which also installs a `FallbackPolicy` that requires an authenticated user. Razor pages gate inline with the canonical role names, e.g. `@attribute [Authorize(Roles = "Administrator,Designer")]`. Nav-menu sections hide via `<AuthorizeView>`.
|
||||
|
||||
### Role grant source
|
||||
|
||||
Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) cluster scope for multi-site fleets. The `RoleGrants.razor` page lets FleetAdmins edit these mappings without leaving the UI.
|
||||
Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI.
|
||||
|
||||
### Headless deploy API (`POST /api/deployments`)
|
||||
|
||||
For CI / scripts that need to trigger a deployment without driving the Blazor "Deploy current configuration" button, admin-role nodes expose `POST /api/deployments` (`DeployApiEndpoints`, `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs`). It forwards to the same `IAdminOperationsClient.StartDeploymentAsync` the button calls.
|
||||
|
||||
Auth is a **single configured secret** checked from the `X-Api-Key` header in fixed time — deliberately orthogonal to the cookie-only web auth (`OPC UA Authentication` above) so automation needs no LDAP login round-trip. The endpoint is `AllowAnonymous` so the `FallbackPolicy` doesn't 401 it, and enforces the key itself. **It self-disables (503) until `Security:DeployApiKey` is set**, so it is never open by default.
|
||||
|
||||
```bash
|
||||
curl -X POST https://<admin-host>/api/deployments \
|
||||
-H 'X-Api-Key: <Security:DeployApiKey>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"createdBy":"ci-bot"}'
|
||||
```
|
||||
|
||||
Responses: `202 Accepted` (`{ outcome, deploymentId, revisionHash }`) when a deployment was sealed, `200` for `NoChanges`, `409` when another deployment is in flight, `422` when rejected, `401` for a missing/wrong key, `503` when unconfigured. Set the secret via `Security:DeployApiKey` (env `Security__DeployApiKey`) on admin nodes only; treat it like any deploy credential (rotate, keep out of source).
|
||||
|
||||
---
|
||||
|
||||
@@ -277,9 +289,9 @@ Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.
|
||||
|
||||
Per-capability resilience (retry, timeout, circuit-breaker, bulkhead) is applied by `CapabilityInvoker` in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/`. A driver-capability call made **outside** the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.
|
||||
|
||||
`OTOPCUA0001` (Roslyn analyzer at `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`) fires as a compile-time **warning** when an `async`/`Task`-returning method on one of the seven guarded capability interfaces (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider`) is invoked **outside** a lambda passed to `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync` / `AlarmSurfaceInvoker.*`. The analyzer walks up the syntax tree from the call site, finds any enclosing invoker invocation, and verifies the call lives transitively inside that invocation's anonymous-function argument — a sibling pattern (do the call, then invoke `ExecuteAsync` on something unrelated nearby) does not satisfy the rule.
|
||||
`OTOPCUA0001` (Roslyn analyzer at `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`) fires with category `OtOpcUa.Resilience` and default severity **Warning** (per `AnalyzerReleases.Shipped.md`) when a method on one of the seven guarded capability interfaces (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider` — all in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`) is invoked **outside** a lambda passed to `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync`. `AlarmSurfaceInvoker` is **not** a wrapper home — its own implementation is covered transitively because it routes through the inner `CapabilityInvoker.ExecuteAsync`. The analyzer walks up the syntax tree from the call site, finds any enclosing invoker invocation, and verifies the call lives transitively inside that invocation's anonymous-function argument — a sibling pattern (do the call, then invoke `ExecuteAsync` on something unrelated nearby) does not satisfy the rule.
|
||||
|
||||
Five xUnit-v3 + Shouldly tests at `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests` cover the common fail/pass shapes + the sibling-pattern regression guard.
|
||||
The xunit.v3 + Shouldly suite at `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs` covers the common fail/pass shapes + the sibling-pattern regression guard.
|
||||
|
||||
The rule is intentionally scoped to async surfaces — pure in-memory accessors like `IHostConnectivityProbe.GetHostStatuses()` return synchronously and do not require the invoker wrap.
|
||||
|
||||
@@ -287,8 +299,8 @@ The rule is intentionally scoped to async surfaces — pure in-memory accessors
|
||||
|
||||
## Audit Logging
|
||||
|
||||
- **Server**: Serilog `AUDIT:` prefix on every authentication success/failure, certificate validation result, write access denial. Written alongside the regular rolling file sink.
|
||||
- **Admin**: `AuditLogService` writes `ConfigAuditLog` rows to the Config DB for every publish, rollback, cluster-node CRUD, credential rotation. Visible in the Audit page for operators with `ConfigViewer` or above.
|
||||
- **Server**: authentication, certificate-validation, and write-denial events are logged through the regular Serilog rolling file sink.
|
||||
- **Admin**: `AuditWriterActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`) writes `ConfigAuditLog` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs`) to the Config DB for publish, rollback, cluster-node CRUD, and credential rotation. Visible on the cluster Audit page (`ClusterAudit.razor`) for operators with `Viewer` or above.
|
||||
|
||||
---
|
||||
|
||||
@@ -296,16 +308,16 @@ The rule is intentionally scoped to async surfaces — pure in-memory accessors
|
||||
|
||||
### Certificate trust failure
|
||||
|
||||
Check `{PkiStoreRoot}/rejected/` for the client's cert. Promote via Admin UI Certificates page, or copy the `.der` file manually to `trusted/`.
|
||||
Check `{PkiStoreRoot}/rejected/` for the client's cert. Copy the `.der` file to `trusted/certs/`; the SDK trust list reloads on the next handshake. The Admin UI Certificates page shows what is in each store but does not move certs.
|
||||
|
||||
### LDAP users can connect but fail authorization
|
||||
|
||||
Verify (a) `OpcUaServer:Ldap:GroupAttribute` returns groups in the form `CN=MyGroup,…` (OtOpcUa strips the `CN=` for matching), (b) a `NodeAcl` grant exists at any level of the node's UNS path that unions to the required permission, (c) `Authorization:StrictMode` is correctly set for the deployment stage.
|
||||
Verify (a) `Security:Ldap:GroupAttribute` (default `memberOf`) returns the user's groups, (b) `Security:Ldap:GroupToRole` maps those groups to the expected roles, and (c) a `NodeAcl` grant exists at some level of the node's scope path that unions to the required permission. The data-plane evaluator is strictly default-deny — there is no fail-open mode to fall back on.
|
||||
|
||||
### LDAP bind rejected as "insecure"
|
||||
|
||||
Set `UseTls = true` + `Port = 636`, or temporarily flip `AllowInsecureLdap = true` in dev. Production Active Directory increasingly refuses plain-LDAP bind under LDAP-signing enforcement.
|
||||
Set `Security:Ldap:Transport = "Ldaps"` (or `"StartTls"`) with the matching port (636 for AD `Ldaps`), or temporarily set `Security:Ldap:AllowInsecure = true` in dev. Production Active Directory increasingly refuses plain-LDAP bind under LDAP-signing enforcement.
|
||||
|
||||
### `AuthorizationGate` denies every call after a publish
|
||||
### Stale ACL trie after a publish
|
||||
|
||||
`AclChangeNotifier` invalidates the `PermissionTrieCache` on publish; a stuck cache is usually a missed notification. Restart the Server as a quick mitigation and file a bug — the design is to stay fresh without restarts.
|
||||
A publish installs a **new generation** into `PermissionTrieCache` via `PermissionTrieBuilder` rather than signalling an invalidation; the evaluator binds each session to a generation. If grants appear stale, confirm the new generation was installed (publish completed) and that sessions re-resolved their auth state — a session past its staleness ceiling fails closed and must re-authenticate. As a last resort `PermissionTrieCache.Invalidate(clusterId)` drops a cluster's cached tries.
|
||||
|
||||
@@ -124,4 +124,5 @@ Each cluster member has a `NodeId` derived as `{PublicHostname}:{Port}` of the A
|
||||
| Driver actors | `Runtime.WithOtOpcUaRuntimeActors` | extension on `AkkaConfigurationBuilder` |
|
||||
| Auth pipeline | `Security.AddOtOpcUaAuth` + `MapOtOpcUaAuth` | extensions on `IServiceCollection` / `IEndpointRouteBuilder` |
|
||||
| OPC UA facade | `OpcUaServer.OpcUaApplicationHost` | runtime host, started by driver-role startup |
|
||||
| Partner-URI advertising | `OpcUaServer.OpcUaApplicationHost.PopulateServerArray` | runs after `_application.Start`, appends `PeerApplicationUris` to the SDK `ServerUris` `StringTable` so `Server.ServerArray` (i=2254) returns self + peers |
|
||||
| Health endpoints | `Host.Health.AddOtOpcUaHealth` + `MapOtOpcUaHealth` | extensions on `IServiceCollection` / `IEndpointRouteBuilder` |
|
||||
|
||||
@@ -67,6 +67,8 @@ The Cluster.Tests project verifies these key values stay correct (`HoconLoaderTe
|
||||
- `SeedNodes`: where new nodes go to join. List one (or two) stable nodes. First node bootstraps the cluster from its own address.
|
||||
- `Roles`: free-form tags Akka gossip propagates. v2 uses `admin` + `driver`; per-role wiring in `Program.cs` reads `OTOPCUA_ROLES` env var, not this list — these two should stay in sync.
|
||||
|
||||
Per-role overlay files (`appsettings.admin.json`, `appsettings.driver.json`, `appsettings.admin-driver.json`) layer on top of base `appsettings.json` based on the parsed `OTOPCUA_ROLES` (alphabetical, joined by `-`). See [ServiceHosting.md § Per-role configuration overlays](../ServiceHosting.md#per-role-configuration-overlays).
|
||||
|
||||
## IClusterRoleInfo
|
||||
|
||||
Anywhere in the host that needs the local node's identity or a view of who-else-is-in-the-cluster, inject `IClusterRoleInfo`:
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ Mirror ScadaLink's layout exactly:
|
||||
|
||||
```
|
||||
src/
|
||||
ZB.MOM.WW.OtOpcUa.Admin/ # Razor Components project (.NET 10)
|
||||
ZB.MOM.WW.OtOpcUa.AdminUI/ # Razor Components project (.NET 10)
|
||||
Auth/
|
||||
AuthEndpoints.cs # /auth/login, /auth/logout, /auth/token
|
||||
CookieAuthenticationStateProvider.cs # bridges cookie auth to Blazor <AuthorizeView>
|
||||
@@ -61,10 +61,10 @@ src/
|
||||
NotAuthorizedView.razor
|
||||
EndpointExtensions.cs # MapAuthEndpoints + role policies
|
||||
ServiceCollectionExtensions.cs # AddCentralAdmin
|
||||
ZB.MOM.WW.OtOpcUa.Admin.Security/ # LDAP + role mapping + JWT (sibling of ScadaLink.Security)
|
||||
ZB.MOM.WW.OtOpcUa.Security/ # LDAP + role mapping + JWT (sibling of ScadaLink.Security)
|
||||
```
|
||||
|
||||
The `Admin.Security` project carries `LdapAuthService`, `RoleMapper`, `JwtTokenService`, `AuthorizationPolicies`. If it ever makes sense to consolidate with ScadaLink's identical project, lift to a shared internal NuGet — out of scope for v2.0 to keep OtOpcUa decoupled from ScadaLink's release cycle.
|
||||
The `Security` project carries `LdapAuthService`, `RoleMapper`, `JwtTokenService`, `AuthorizationPolicies`. If it ever makes sense to consolidate with ScadaLink's identical project, lift to a shared internal NuGet — out of scope for v2.0 to keep OtOpcUa decoupled from ScadaLink's release cycle.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ Running record of v2 dev services on the Windows dev VM. Updated on every instal
|
||||
|---------|---------------------|---------|-----------|------------------------|---------------|--------|
|
||||
| **Central config DB** | Docker container `otopcua-mssql` on the Linux Docker host (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `10.100.0.35:14330` → `1433` (container) — port 14330 retained from the previous local-container setup so connection-string ports don't churn | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` on the Docker host | ✅ Running on Docker host (`/opt/otopcua-mssql/`) since 2026-04-28; carries `project=lmxopcua` label |
|
||||
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box — full ArchestrA + Historian + OI-Server stack | v1 baseline | Local COM via MXAccess (`C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`); Historian via `aaH*` services; SuiteLink via `slssvc` | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ **Fully available — Phase 2 lift unblocked.** 27 ArchestrA / AVEVA / Wonderware services running incl. `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`, `AutoBuild_Service`; full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `aahSupervisor`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `InSQLManualStorage`, `InSQLSystemDriver`, `HistorianSearch-x64`); `slssvc` (Wonderware SuiteLink); `OI-Gateway` install present at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (decision #142 AppServer-via-OI-Gateway smoke test now also unblocked) |
|
||||
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=lmxopcua,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. v2-rebrand to `dc=otopcua,dc=local` is a future cosmetic change |
|
||||
| GLAuth (LDAP) | Docker container `zb-shared-glauth` on the Linux Docker host — managed via `scadaproj/infra/glauth/` | v2.4.0 | `10.100.0.35:3893` (LDAP plaintext; LDAPS disabled) | Bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`; baseDN `dc=zb,dc=local` | `scadaproj/infra/glauth/` (source of truth + deploy/verify runbook) | ✅ Running on Docker host. Shared across OtOpcUa, MxAccessGateway, ScadaBridge. OtOpcUa groups: `OtOpcUa-Admins`→Administrator, `OtOpcUa-Designers`→Designer, `OtOpcUa-Viewers`→Viewer. The per-VM NSSM service at `C:\publish\glauth\` and old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. |
|
||||
| OPC Foundation reference server | Not yet built | — | `10.100.0.35:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
|
||||
| FOCAS TCP stub | Not yet built | — | `10.100.0.35:8193` (target) | n/a | — | Pending (built in Phase 5; runs on Docker host) |
|
||||
| Modbus simulator (`otopcua-pymodbus:3.13.0`) | Docker compose at `/opt/otopcua-modbus/` on Docker host | pinned 3.13.0 | `10.100.0.35:5020` | n/a | n/a | Stack staged; bring up with `lmxopcua-fix up modbus <profile>` from this VM |
|
||||
@@ -85,17 +85,19 @@ Copy-paste-ready. The checked-in `appsettings.json` defaults already point at th
|
||||
},
|
||||
"Authentication": {
|
||||
"Ldap": {
|
||||
"Host": "localhost",
|
||||
"Host": "10.100.0.35",
|
||||
"Port": 3893,
|
||||
"UseLdaps": false,
|
||||
"BindDn": "cn=admin,dc=otopcua,dc=local",
|
||||
"BindPassword": "<see glauth-otopcua.cfg — pending seeding>"
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
LDAP host stays `localhost` because GLAuth still runs as a native NSSM service on this dev VM (not yet migrated to the Docker host).
|
||||
LDAP now points at the shared GLAuth on the Linux Docker host (`10.100.0.35:3893`, baseDN `dc=zb,dc=local`). The per-VM NSSM service at `C:\publish\glauth\` is obsolete. See `scadaproj/infra/glauth/` for the source of truth and deploy runbook.
|
||||
|
||||
For xUnit test fixtures that need a throwaway DB per test run, build connection strings with `Database=OtOpcUaConfig_Test_{timestamp}` to avoid cross-run pollution.
|
||||
|
||||
@@ -139,7 +141,7 @@ Dev credentials in this inventory are convenience defaults, not secrets. Change
|
||||
| Resource | Purpose | Type | Default port | Default credentials | Owner |
|
||||
|----------|---------|------|--------------|---------------------|-------|
|
||||
| **SQL Server 2022 dev edition** | Central config DB; integration tests against `Configuration` project | Local install OR Docker container `mcr.microsoft.com/mssql/server:2022-latest` | 1433 default, or 14330 when a native MSSQL instance (e.g. the Galaxy `ZB` host) already occupies 1433 | `sa` / `OtOpcUaDev_2026!` (dev only — production uses Integrated Security or gMSA per decision #46) | Developer (per machine) |
|
||||
| **GLAuth (LDAP server)** | Admin UI authentication tests; data-path ACL evaluation tests | Local binary at `C:\publish\glauth\` per existing CLAUDE.md | 3893 (LDAP) / 3894 (LDAPS) | Service principal: `cn=admin,dc=otopcua,dc=local` / `OtOpcUaDev_2026!`; test users defined in GLAuth config | Developer (per machine) |
|
||||
| **GLAuth (LDAP server)** | Admin UI authentication tests; data-path ACL evaluation tests | Shared Docker container `zb-shared-glauth` on the Linux Docker host at `10.100.0.35:3893` — managed via `scadaproj/infra/glauth/`; no per-developer install required | 3893 (LDAP plaintext) | Bind `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; baseDN `dc=zb,dc=local`; test users password `password` | Shared (Docker host — `scadaproj/infra/glauth/`) |
|
||||
| **Local dev Galaxy** (Aveva System Platform) | Galaxy driver tests; v1 IntegrationTests parity | Existing on dev box per CLAUDE.md | n/a (local COM) | Windows Auth | Developer (already present per project setup) |
|
||||
|
||||
### C. Integration host (one dedicated Windows machine the team shares)
|
||||
|
||||
@@ -104,8 +104,8 @@ Anonymous OPC UA sessions are denied writes against `Operate`-classified tags by
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"GroupToRole": {
|
||||
"ReadOnly": "ReadOnly",
|
||||
|
||||
@@ -96,7 +96,7 @@ Shipped as PR #183 (12 tests in configuration; 13 more in Admin.Tests).
|
||||
| F.4 — Test harness (modal, synthetic inputs, output + logger display) | **Partial** | `ScriptTestHarnessService.cs` is complete and tested. `ScriptsTab.razor` calls `Harness.RunVirtualTagAsync` with zero-value synthetic inputs derived from the extractor. A full interactive input-form modal was not shipped — the harness zeroes all inputs automatically rather than prompting the operator per-tag. |
|
||||
| F.5 — Script log viewer (SignalR tail of `scripts-*.log` filtered by `ScriptName`, load-more) | **Not started** | No SignalR stream of the scripts log is wired in the Admin UI. The `AlertHub` / `FleetStatusHub` exist but there is no `ScriptLogHub`. |
|
||||
| F.6 — `/alarms/historian` diagnostics view | **Done** | `AlarmsHistorian.razor` + `HistorianDiagnosticsService.cs` |
|
||||
| F.7 — Playwright smoke (author calc tag, verify in equipment tree; author alarm, verify in `AlarmsAndConditions`) | **Not started** | `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/` exists but its `UnsTabDragDropE2ETests.cs` is the only Playwright test; no Phase 7 Admin UI playwright scenario. |
|
||||
| F.7 — Playwright smoke (author calc tag, verify in equipment tree; author alarm, verify in `AlarmsAndConditions`) | **Not started** | No Phase 7 Playwright/E2E project exists in the repo today; future-work item without an assigned path. |
|
||||
|
||||
Shipped as PR #185 (13 Admin service tests; UI completeness is partial — see gaps section).
|
||||
|
||||
@@ -190,8 +190,8 @@ The SignalR tail of `scripts-*.log` filtered by `ScriptName` was not implemented
|
||||
| `Core.VirtualTags` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/` |
|
||||
| `Core.ScriptedAlarms` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/` |
|
||||
| `Core.AlarmHistorian` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/` |
|
||||
| Server Phase7 composition | `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/` |
|
||||
| Admin services | `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/Script*.cs`, `VirtualTagService.cs`, `HistorianDiagnosticsService.cs` |
|
||||
| Admin UI pages | `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor`, `AlarmsHistorian.razor` |
|
||||
| Server Phase7 composition | `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`, `Phase7Applier.cs`, `Phase7Plan.cs` |
|
||||
| Admin services (CRUD writes) | `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs` (actor-driven); live state in `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs`, `Runtime/VirtualTags/VirtualTagActor.cs`; Roslyn engines in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/` — v1 `Admin/Services/Script*.cs`, `VirtualTagService.cs`, `HistorianDiagnosticsService.cs` deleted |
|
||||
| Admin UI pages | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor`, `ScriptEdit.razor`, `ScriptedAlarms.razor`, `ScriptedAlarmEdit.razor`, `AlarmsHistorian.razor`, `VirtualTags.razor`, `VirtualTagEdit.razor` |
|
||||
| Historian sidecar writer | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs` |
|
||||
| EF migrations | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs`, `20260420232000_ExtendComputeGenerationDiffWithPhase7.cs` |
|
||||
|
||||
@@ -55,6 +55,7 @@ Each row is one manual run; pass criterion in the right column.
|
||||
| A2 | ServiceLevel updates on peer down | Connect to Primary. Stop Backup (`sc stop OtOpcUa`). Watch `ServiceLevel`. | Transitions 200 → 150 within ~2 s of peer probe timeout |
|
||||
| A3 | RedundancySupport | Browse to `Server.ServerRedundancy.RedundancySupport`. | Value matches the declared `RedundancyMode` (Warm / Hot / None) |
|
||||
| A4 | ServerUriArray (non-transparent upgrade) | Requires a redundancy-object-type upgrade follow-up. | When upgrade lands: `ServerUriArray` reports both ApplicationUris, self first |
|
||||
| A4b | Peer URI visibility via `Server.ServerArray` (i=2254) | Configure each `OpcUaApplicationHost` with the partner's `ApplicationUri` via `OpcUaApplicationHostOptions.PeerApplicationUris`. From any client, Read NodeId `i=2254` (`Server.ServerArray`). | Returned `String[]` includes both self + peer `ApplicationUri`s. Validated by `DualEndpointTests` in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/` (loopback dual-host with real OPCFoundation client `Session` read). |
|
||||
| A5 | Mid-apply dip | On Primary trigger a `sp_PublishGeneration` apply. | `ServiceLevel` drops to 75 for the apply duration + dwell |
|
||||
|
||||
### Block B — Client failover
|
||||
@@ -101,7 +102,9 @@ flips A4 from "deferred" to "expected pass").
|
||||
- **A4 pending**: `Server.ServerRedundancy` on our current SDK build lands as
|
||||
the base `ServerRedundancyState`, which has no `ServerUriArray` child.
|
||||
`ServerRedundancyNodeWriter.ApplyServerUriArray` logs-and-skips until the
|
||||
redundancy-object-type upgrade follow-up lands.
|
||||
redundancy-object-type upgrade follow-up lands. Cross-reference **A4b** —
|
||||
peer URIs are visible today via `Server.ServerArray` (i=2254) populated by
|
||||
`OpcUaApplicationHost.PopulateServerArray`.
|
||||
- **Recovery dwell default**: `RecoveryStateManager.DwellTime` defaults to 60 s
|
||||
in `Program.cs`. Adjust via future config knob if B3 takes too long to
|
||||
observe.
|
||||
@@ -121,8 +124,8 @@ flips A4 from "deferred" to "expected pass").
|
||||
redundancy implementations we don't control.
|
||||
- For the sub-set of scenarios that *can* be automated — the self-loopback
|
||||
case where our own `otopcua-cli` drives Primary + Backup — the existing
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/RedundancyStatePublisherTests` +
|
||||
`ServiceLevelCalculatorTests` (unit) + `ClusterTopologyLoaderTests`
|
||||
(integration) already cover the math + data path. The wire-level assertion
|
||||
that the values actually land on the right OPC UA nodes is covered by
|
||||
`ServerRedundancyNodeWriterTests`.
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests` +
|
||||
`ServiceLevelCalculatorTests` (unit) already cover the math + data path.
|
||||
The wire-level assertion that the peer URIs actually land on the
|
||||
`Server.ServerArray` node (i=2254) is covered by `DualEndpointTests` in
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/`.
|
||||
|
||||
@@ -57,7 +57,7 @@ Remaining follow-ups (hardening):
|
||||
Remaining Phase 6.3 surfaces (hardening, not release-blocking):
|
||||
|
||||
- ~~`PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices populating `PeerReachabilityTracker` on each tick.~~ **Closed 2026-04-24.** Two-layer probe model shipped: HTTP probe at 2 s / 1 s timeout against `/healthz`; OPC UA probe at 10 s / 5 s timeout via `DiscoveryClient.GetEndpoints`, short-circuiting when HTTP reports the peer unhealthy. Registered on the Server as `AddHostedService<PeerHttpProbeLoop>` + `AddHostedService<PeerUaProbeLoop>`. Publisher now sees accurate `PeerReachability` per peer instead of degrading to `Unknown` → Isolated-Primary band (230).
|
||||
- OPC UA variable-node wiring: bind `ServiceLevel` Byte + `ServerUriArray` String[] to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push.
|
||||
- ~~OPC UA variable-node wiring: bind `ServiceLevel` Byte + `ServerUriArray` String[] to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push.~~ **Closed 2026-05-26.** `ServiceLevel` byte binding closed earlier under Path D. Peer-URI half closed via `OpcUaApplicationHost.PopulateServerArray` — populates self + each `PeerApplicationUris` entry into the SDK `IServerInternal.ServerUris` `StringTable`; clients read `Server.ServerArray` (NodeId `i=2254`). Validated by `DualEndpointTests` in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/`. `ServerUriArray` proper (the redundancy-object-type child) remains deferred pending object-type upgrade.
|
||||
- ~~`sp_PublishGeneration` wraps its apply in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band (200) fires during actual publishes (task #148 part 2).~~ **Closed 2026-04-24.** The apply loop now lives in `GenerationRefreshHostedService` — polls `sp_GetCurrentGenerationForCluster` every 5s, opens a lease when a new generation is detected, calls `RedundancyCoordinator.RefreshAsync` inside the `await using`, releases the lease on all exit paths. Replaces the previous "topology never refreshes without a process restart" behaviour.
|
||||
- Client interop matrix — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only.
|
||||
|
||||
@@ -118,6 +118,7 @@ v2 GA requires all of the following:
|
||||
|
||||
## Change log
|
||||
|
||||
- **2026-05-26** — Gap-closeout pass. `OpcUaApplicationHost.PopulateServerArray` populates `Server.ServerArray` (NodeId `i=2254`) with self + `OpcUaApplicationHostOptions.PeerApplicationUris`, giving non-transparent peer URI visibility through the standard discovery surface. New `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/` IT project (`DualEndpointTests`) validates with two real `OpcUaApplicationHost` instances on loopback + a live OPCFoundation client `Session` read. CI `v2-ci.yml` `integration:` job converted to a matrix across `Host.IntegrationTests` + `OpcUaServer.IntegrationTests`. Per-role appsettings overlays shipped (`appsettings.admin.json` / `appsettings.driver.json` / `appsettings.admin-driver.json`) — `Program.cs:33-35` loads by alphabetical-joined role suffix. `FailoverScenarioTests` → `FailoverDuringDeployTests` rename. Stale empty `src/Server/{Server,Admin}` + `tests/Server/{Server.Tests,Admin.Tests,Admin.E2ETests}` directories deleted (no source, absent from `.slnx`).
|
||||
- **2026-04-24** — Phase 5 driver complement closed (task #120 CLOSED). AB CIP, AB Legacy, TwinCAT, FOCAS all shipped. FOCAS migration: retired the Tier-C split (`Driver.FOCAS.Host` + `Driver.FOCAS.Shared` + `FwlibNative` + shim DLL deleted) in favour of a pure-managed in-process `FocasWireClient` inlined into `Driver.FOCAS`; driver is now read-only against the CNC by design. Integration test matrix grew to cover Browse / Subscribe / IAlarmSource / Probe end-to-end.
|
||||
- **2026-04-23** — Phase 6.4 audit close-out. IdentificationFolderBuilder + OPC 40010 Identification folder verified against the shipped code.
|
||||
- **2026-04-20** — Phase 7 plan drafted (`phase-7-scripting-and-alarming.md`, `phase-7-e2e-smoke.md`). Out of scope for v2 GA.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -42,6 +42,7 @@ public class AlarmsCommand : CommandBase
|
||||
/// Connects to the server, subscribes to alarm events, and streams operator-facing alarm state changes to the console.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -36,10 +36,7 @@ public class BrowseCommand : CommandBase
|
||||
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
|
||||
public bool Recursive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints a tree view of the requested address-space branch.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -15,10 +15,7 @@ public class ConnectCommand : CommandBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints the negotiated endpoint details for operator verification.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -56,10 +56,7 @@ public class HistoryReadCommand : CommandBase
|
||||
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
|
||||
public double IntervalMs { get; init; } = 3600000;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints raw or processed historical values for the requested node.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -24,10 +24,7 @@ public class ReadCommand : CommandBase
|
||||
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints the current value, status, and timestamps for the requested node.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -15,10 +15,8 @@ public class RedundancyCommand : CommandBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints redundancy mode, service level, and partner-server identity data.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <summary>Connects to the server and prints redundancy mode, service level, and partner-server identity data.</summary>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -67,11 +67,7 @@ public class SubscribeCommand : CommandBase
|
||||
[CommandOption("summary-file", Description = "Write summary to this file path on exit (in addition to stdout)")]
|
||||
public string? SummaryFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server, subscribes to <see cref="NodeId" /> (or its subtree when recursive),
|
||||
/// streams data-change notifications to the console, and prints a summary when the command exits.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
@@ -35,6 +35,7 @@ public class WriteCommand : CommandBase
|
||||
/// Connects to the server, converts the supplied value to the node's current data type, and issues the write.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultApplicationConfigurationFactory>();
|
||||
|
||||
/// <summary>Creates an OPC UA application configuration from the provided connection settings.</summary>
|
||||
/// <param name="settings">The connection settings to use.</param>
|
||||
/// <param name="ct">Token to cancel the operation.</param>
|
||||
public async Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
|
||||
{
|
||||
// Resolve the canonical PKI path lazily on first use so constructing a
|
||||
|
||||
@@ -11,6 +11,10 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
|
||||
|
||||
/// <summary>Selects an OPC UA endpoint matching the requested security mode.</summary>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="endpointUrl">The endpoint URL to query.</param>
|
||||
/// <param name="requestedMode">The requested message security mode.</param>
|
||||
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,14 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultSessionFactory>();
|
||||
|
||||
/// <summary>Creates a new OPC UA session.</summary>
|
||||
/// <param name="config">The OPC UA application configuration.</param>
|
||||
/// <param name="endpoint">The endpoint description to connect to.</param>
|
||||
/// <param name="sessionName">The name for the session.</param>
|
||||
/// <param name="sessionTimeoutMs">The session timeout in milliseconds.</param>
|
||||
/// <param name="identity">The user identity for the session.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>An adapter wrapping the created session.</returns>
|
||||
public async Task<ISessionAdapter> CreateSessionAsync(
|
||||
ApplicationConfiguration config,
|
||||
EndpointDescription endpoint,
|
||||
|
||||
+2
@@ -11,5 +11,7 @@ internal interface IApplicationConfigurationFactory
|
||||
/// <summary>
|
||||
/// Creates a validated ApplicationConfiguration for the given connection settings.
|
||||
/// </summary>
|
||||
/// <param name="settings">The connection settings to configure.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct = default);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ internal interface IEndpointDiscovery
|
||||
/// Discovers endpoints at the given URL and returns the best match for the requested security mode.
|
||||
/// Also rewrites the endpoint URL hostname to match the requested URL when they differ.
|
||||
/// </summary>
|
||||
/// <param name="config">The OPC UA application configuration.</param>
|
||||
/// <param name="endpointUrl">The endpoint URL to discover.</param>
|
||||
/// <param name="requestedMode">The requested message security mode.</param>
|
||||
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public static class AggregateTypeMapper
|
||||
/// <summary>
|
||||
/// Returns the OPC UA NodeId for the specified aggregate type.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">The aggregate type to map to a NodeId.</param>
|
||||
/// <returns>The OPC UA NodeId for the aggregate function.</returns>
|
||||
public static NodeId ToNodeId(AggregateType aggregate)
|
||||
{
|
||||
return aggregate switch
|
||||
|
||||
@@ -8,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
/// </summary>
|
||||
public static class SecurityModeMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a <see cref="SecurityMode" /> to an OPC UA <see cref="MessageSecurityMode" />.
|
||||
/// </summary>
|
||||
/// <summary>Converts a SecurityMode to an OPC UA MessageSecurityMode.</summary>
|
||||
/// <param name="mode">The security mode to convert.</param>
|
||||
/// <returns>The corresponding message security mode.</returns>
|
||||
public static MessageSecurityMode ToMessageSecurityMode(SecurityMode mode)
|
||||
{
|
||||
return mode switch
|
||||
|
||||
@@ -5,5 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
/// </summary>
|
||||
public interface IOpcUaClientServiceFactory
|
||||
{
|
||||
/// <summary>Creates a new OPC UA client service instance.</summary>
|
||||
/// <returns>A new <see cref="IOpcUaClientService"/> instance.</returns>
|
||||
IOpcUaClientService Create();
|
||||
}
|
||||
@@ -5,6 +5,20 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
/// </summary>
|
||||
public sealed class AlarmEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="AlarmEventArgs"/> class.</summary>
|
||||
/// <param name="sourceName">The name of the source object that raised the alarm.</param>
|
||||
/// <param name="conditionName">The condition type name.</param>
|
||||
/// <param name="severity">The alarm severity (0-1000).</param>
|
||||
/// <param name="message">Human-readable alarm message.</param>
|
||||
/// <param name="retain">Whether the alarm should be retained in the display.</param>
|
||||
/// <param name="activeState">Whether the alarm condition is currently active.</param>
|
||||
/// <param name="ackedState">Whether the alarm has been acknowledged.</param>
|
||||
/// <param name="time">The time the event occurred.</param>
|
||||
/// <param name="eventId">The EventId used for alarm acknowledgment.</param>
|
||||
/// <param name="conditionNodeId">The NodeId of the condition instance.</param>
|
||||
/// <param name="operatorComment">Operator-supplied comment on acknowledgment transitions.</param>
|
||||
/// <param name="originalRaiseTimestampUtc">When the alarm originally entered the active state.</param>
|
||||
/// <param name="alarmCategory">Upstream alarm taxonomy bucket (e.g. Process, Safety, Diagnostics).</param>
|
||||
public AlarmEventArgs(
|
||||
string sourceName,
|
||||
string conditionName,
|
||||
|
||||
@@ -5,6 +5,11 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
/// </summary>
|
||||
public sealed class BrowseResult
|
||||
{
|
||||
/// <summary>Initializes a new instance of the BrowseResult class.</summary>
|
||||
/// <param name="nodeId">The string representation of the node's NodeId.</param>
|
||||
/// <param name="displayName">The display name of the node.</param>
|
||||
/// <param name="nodeClass">The node class (e.g., "Object", "Variable", "Method").</param>
|
||||
/// <param name="hasChildren">Whether the node has child references.</param>
|
||||
public BrowseResult(string nodeId, string displayName, string nodeClass, bool hasChildren)
|
||||
{
|
||||
NodeId = nodeId;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user