129 lines
7.3 KiB
Markdown
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` |
|