tag gate, and fix three silently-broken OpcUaClient integration tests
Continues working through deferment.md's remaining items.
SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
- REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
E2E. The behaviour never went dark, only the v2 seeding did.
- FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
already compared RawTagPlan record-equal and RawTagPlan carries array /
historize / alarm intent, so widening its corpus by two tags covers what
three empty placeholder suites had been promising. Those suites are deleted.
- DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
and both dot-joint {{equip}}.X token suites (that resolver was deleted in
Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.
G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.
OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.
DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
7.3 KiB
OtOpcUa v2 Architecture
Single-page tour of the v2 layout. For decision history + tradeoffs, see 2026-05-26-akka-hosting-alignment-design.md.
Big picture
┌─────────────────────────────────────────────┐
│ OtOpcUa.Host │ (fused binary)
│ │
│ reads OTOPCUA_ROLES env, mounts: │
│ ┌─────────────────────────────────────┐ │
│ │ admin → Blazor + auth + control- │ │
│ │ plane singletons │ │
│ │ driver → OPC UA endpoint + │ │
│ │ per-node actors │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│
│ joins
▼
┌─────────────────────────────────────────────┐
│ Akka.NET cluster │
│ (split-brain resolver: keep-oldest, 15s) │
└─────────────────────────────────────────────┘
shared by every node: ┌─────────────────┐
│ ConfigDb (SQL) │ live-edit + Deployment artifacts + audit
└─────────────────┘
The v1 setup was two separate Windows services (OtOpcUa.Server + OtOpcUa.Admin) talking through the DB. v2 collapses them into one binary with role gating, and adds an Akka cluster so admin singletons can drive deploys and the redundancy story is automatic.
Project layout
src/Core/ shared abstractions, no Server deps
ZB.MOM.WW.OtOpcUa.Commons types + Akka message contracts + interfaces
ZB.MOM.WW.OtOpcUa.Cluster HOCON, AkkaClusterOptions, IClusterRoleInfo
ZB.MOM.WW.OtOpcUa.Configuration EF Core DbContext + entities
src/Server/ server-side projects
ZB.MOM.WW.OtOpcUa.Security cookie+JWT auth, LDAP, JwtTokenService
ZB.MOM.WW.OtOpcUa.ControlPlane admin-role cluster singletons
ZB.MOM.WW.OtOpcUa.Runtime driver-role per-node actors
ZB.MOM.WW.OtOpcUa.OpcUaServer OPC UA endpoint facade + AddressSpaceComposer
ZB.MOM.WW.OtOpcUa.AdminUI Blazor Razor class library
ZB.MOM.WW.OtOpcUa.Host fused binary (Program.cs)
| Project | Role | Doc |
|---|---|---|
| Cluster | Bootstrap + cluster topology view | Cluster.md |
| ControlPlane | Admin singletons (deploy, audit, fleet, redundancy) | ControlPlane.md |
| Runtime | Driver-role actor tree | Runtime.md |
| Security | Cookie+JWT auth, LDAP, /auth/* endpoints | ../security.md |
| OpcUaServer | OPC UA endpoint host + composer | ../OpcUaServer.md |
| Host | Role-gated DI graph + Program.cs | ../ServiceHosting.md |
Role gating
Program.cs reads OTOPCUA_ROLES once (per process) and decides what to wire:
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
var hasAdmin = roles.Contains("admin");
var hasDriver = roles.Contains("driver");
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
builder.Services.AddOtOpcUaCluster(builder.Configuration);
builder.Services.AddAkka("otopcua", (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp); // HOCON + remote + cluster options
if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons();
if (hasDriver) ab.WithOtOpcUaRuntimeActors();
});
if (hasAdmin)
{
builder.Services.AddOtOpcUaAuth(builder.Configuration);
builder.Services.AddAdminUI();
// SignalR, AdminOpsClient, etc.
}
builder.Services.AddOtOpcUaHealth();
There is a single ActorSystem. Cluster singletons + per-node actors share it via the Akka.Hosting registry. This was a v2 fix (the initial Phase 9 wiring ran two ActorSystems by mistake; see commit d6fac2d).
Live-edit vs draft/publish
v1 had ConfigGeneration(Draft|Published) with every live-edit entity FK'd to a generation. Edits accumulated in a Draft until Publish promoted them.
v2 removes that entirely:
- No
ConfigGenerationtable, noGenerationIdcolumns. - Every live-edit entity has a
RowVersion(IsRowVersion()) for last-write-wins. - Audit goes to
ConfigEdit(per-row delta) andConfigAuditLog(event-level). - Deploys snapshot the current DB state into an immutable
Deployment.ArtifactBlob+ itsRevisionHash. That artifact is what driver nodes apply.
See ControlPlane.md § Deploy flow for the end-to-end dispatch + ACK + seal sequence.
NodeId
Each cluster member has a NodeId derived as {PublicHostname}:{Port} of the Akka remote endpoint. ClusterRoleInfo.LocalNode + ConfigPublishCoordinator.DiscoverDriverNodes() use the same formula so they always agree. The port suffix makes loopback test deployments distinguishable (commit 5cfbe8b); in production the hostname alone is already unique.
Health endpoints
| Path | Returns 200 when… |
|---|---|
/healthz |
Process is alive (no checks). |
/health/ready |
DB reachable + this node is Up in the cluster. |
/health/active |
This node is the admin role-leader (used by Traefik/HA-LB to pin traffic). |
What lives where (quick map)
| Concern | Project | Entry point |
|---|---|---|
| Read OTOPCUA_ROLES | Cluster.RoleParser |
static Parse(string?) |
| Cluster lifecycle | Cluster.WithOtOpcUaClusterBootstrap |
extension on AkkaConfigurationBuilder |
| Local node identity | Cluster.IClusterRoleInfo.LocalNode |
DI singleton |
| Admin singletons | ControlPlane.WithOtOpcUaControlPlaneSingletons |
extension on AkkaConfigurationBuilder |
| 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 |