Files
lmxopcua/docs/v2/Architecture-v2.md

129 lines
7.3 KiB
Markdown

# OtOpcUa v2 Architecture
Single-page tour of the v2 layout. For decision history + tradeoffs, see [`2026-05-26-akka-hosting-alignment-design.md`](../plans/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 + Phase7Composer
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](Cluster.md) |
| ControlPlane | Admin singletons (deploy, audit, fleet, redundancy) | [ControlPlane.md](ControlPlane.md) |
| Runtime | Driver-role actor tree | [Runtime.md](Runtime.md) |
| Security | Cookie+JWT auth, LDAP, /auth/* endpoints | [../security.md](../security.md) |
| OpcUaServer | OPC UA endpoint host + composer | [../OpcUaServer.md](../OpcUaServer.md) |
| Host | Role-gated DI graph + Program.cs | [../ServiceHosting.md](../ServiceHosting.md) |
## Role gating
`Program.cs` reads `OTOPCUA_ROLES` once (per process) and decides what to wire:
```csharp
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 `ConfigGeneration` table, no `GenerationId` columns.
- Every live-edit entity has a `RowVersion` (`IsRowVersion()`) for last-write-wins.
- Audit goes to `ConfigEdit` (per-row delta) and `ConfigAuditLog` (event-level).
- Deploys snapshot the *current* DB state into an immutable `Deployment.ArtifactBlob` + its `RevisionHash`. That artifact is what driver nodes apply.
See [ControlPlane.md § Deploy flow](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` |