Compare commits
28 Commits
v2-akka-fu
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
10
.github/workflows/v2-ci.yml
vendored
10
.github/workflows/v2-ci.yml
vendored
@@ -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"
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
<Folder Name="/tests/Server/">
|
||||
<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" />
|
||||
|
||||
@@ -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 + OpenLDAP + 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 runs in `DevStubMode` — every host container has `Authentication__Ldap__DevStubMode=true` set, so the LDAP service is not part of the dev compose right now (the `bitnami/openldap:2.6` image was retired and the legacy tag crashes mid-setup with exit 68). Any non-empty username/password signs in as `FleetAdmin`. To restore a real LDAP service, drop the env var and add an `openldap`-compatible image back to compose.
|
||||
|
||||
### 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`.
|
||||
`Authentication__Ldap__DevStubMode=true` is set on every host container, so any non-empty username/password signs in as a `FleetAdmin` user without contacting an LDAP server. **Do not** ship this configuration to production — set `DevStubMode=false` and wire a real LDAP backend before any non-dev deployment.
|
||||
|
||||
## Tear down
|
||||
|
||||
@@ -51,12 +98,15 @@ The `-v` drops the SQL + LDAP volumes; remove it to keep ConfigDb state across r
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,17 +57,26 @@ services:
|
||||
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
|
||||
# Authentication__Ldap__DevStubMode=true, so any non-empty username/password
|
||||
# signs in as `FleetAdmin`. 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 +97,8 @@ 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"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
|
||||
admin-b:
|
||||
<<: *otopcua-host
|
||||
@@ -83,9 +114,8 @@ 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"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
|
||||
driver-a:
|
||||
<<: *otopcua-host
|
||||
@@ -97,6 +127,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_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
ports:
|
||||
- "4840:4840"
|
||||
|
||||
@@ -110,9 +143,103 @@ 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_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
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"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
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"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
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"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
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"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
ports:
|
||||
- "4845:4840"
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
command:
|
||||
@@ -122,9 +249,13 @@ services:
|
||||
- --api.insecure=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8080:8080"
|
||||
- "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
|
||||
|
||||
48
docker-dev/seed/entrypoint.sh
Executable file
48
docker-dev/seed/entrypoint.sh
Executable file
@@ -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."
|
||||
195
docker-dev/seed/seed-clusters.sql
Normal file
195
docker-dev/seed/seed-clusters.sql
Normal file
@@ -0,0 +1,195 @@
|
||||
-- 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;
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
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; in v2 the SDK-driven materialization is handled by `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) fed via `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`). 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
|
||||
|
||||
@@ -66,7 +66,7 @@ Drivers that implement `IRediscoverable` fire `OnRediscoveryNeeded` when their b
|
||||
## 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/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
|
||||
|
||||
@@ -15,9 +15,10 @@ historical reference.
|
||||
| **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` |
|
||||
|
||||
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
|
||||
|
||||
@@ -28,7 +28,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`.
|
||||
@@ -64,6 +64,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
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# 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/OtOpcUaSdkServer.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/`.
|
||||
|
||||
## Composition
|
||||
|
||||
`OtOpcUaServer` subclasses the OPC Foundation `StandardServer` and wires:
|
||||
|
||||
- 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.
|
||||
- One `DriverNodeManager` per registered driver (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.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.
|
||||
@@ -50,7 +50,7 @@ The host name fed to the invoker comes from `IPerCallHostResolver.ResolveHost(fu
|
||||
|
||||
## 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/`). 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`.
|
||||
|
||||
## Server class hierarchy
|
||||
|
||||
@@ -79,10 +79,11 @@ Certificate stores default to `%LOCALAPPDATA%\OPC Foundation\pki\` (directory-ba
|
||||
|
||||
## 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/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs` — `StandardServer` subclass
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs` — programmatic `ApplicationConfiguration` + lifecycle + `ImpersonateUser` hook
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — SDK node manager + write-only address-space sink
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` — `IOpcUaAddressSpaceSink` adapter the actor system pushes into
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — per-driver discovery + dispatch surface
|
||||
- `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/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`)
|
||||
|
||||
@@ -59,7 +59,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
| [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.
|
||||
`GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.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.
|
||||
|
||||
## Driver vs virtual dispatch
|
||||
|
||||
@@ -60,8 +60,7 @@ 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` — `OnReadValue` / `OnWriteValue` hooks
|
||||
- `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`
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## 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.
|
||||
|
||||
> **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 + health drive ServiceLevel automatically.
|
||||
|
||||
@@ -78,6 +80,20 @@ Both nodes share the same `ConfigDb` connection string; `Cluster.PublicHostname`
|
||||
|
||||
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).
|
||||
|
||||
### 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. Set this per-node in `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUaServer": {
|
||||
"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
|
||||
|
||||
`akka.conf` configures Akka's split-brain resolver with `active-strategy = keep-oldest`, `stable-after = 15s`, and `failure-detector.threshold = 10.0`. Under a clean partition: the oldest member stays up + the smaller (or younger) side downs itself within ~15 seconds. The `RedundancyStateActor` on the surviving partition re-computes from the post-partition `Cluster.State`.
|
||||
|
||||
@@ -111,13 +111,13 @@ 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 the single call site that instantiates the engine. It takes the generation's `Script` / `VirtualTag` / `ScriptedAlarm` rows, the shared upstream-tag source, an `IAlarmStateStore`, and an `IAlarmHistorianSink`, and returns the composed sources the caller owns. When `scriptedAlarms.Count > 0`:
|
||||
|
||||
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`.
|
||||
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 active-state node (task #245) — unknown alarm ids return `BadNodeIdUnknown` rather than silently reading `false`.
|
||||
|
||||
Both engine and source are added to `Phase7ComposedSources.Disposables`, which `Phase7Composer` disposes on server shutdown.
|
||||
|
||||
@@ -132,5 +132,7 @@ Both engine and source are added to `Phase7ComposedSources.Disposables`, which `
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs` — `AlarmKind` + the four Part 9 enums
|
||||
- `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` — composition, config-row projection, historian routing
|
||||
- `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 wrapper owning the alarm state machine and exposing `ActiveState` for OPC UA variable reads
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs` — production Roslyn predicate evaluator
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -107,13 +107,12 @@ Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B,
|
||||
|
||||
`ITagUpstreamSource` and `IHistoryWriter` are the two ports the engine requires from its host. Both live in `Core.VirtualTags`. In the Server process:
|
||||
|
||||
- **`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.
|
||||
- **Upstream-tag feed.** In v2 the upstream-tag feed is provided by the actor system. `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) multiplexes driver `ISubscribable` subscriptions for every fullRef the script graph references, translating driver-opaque fullRefs back to UNS paths via a reverse map. Deltas land on `VirtualTagActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs`) as `DependencyValueChanged` messages; the actor's in-memory cache serves the engine's synchronous `GetTag` reads. Reads of never-pushed paths return `BadNodeIdUnknown` quality (`UpstreamNotConfigured = 0x80340000`).
|
||||
- **`IHistoryWriter`** — no production implementation is currently wired for virtual tags; `VirtualTagEngine` gets `NullHistoryWriter` by default from `Phase7EngineComposer`.
|
||||
|
||||
## 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`) projects the published generation into a `Phase7Plan` that `Phase7Applier` applies to the running SDK node manager:
|
||||
|
||||
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`.
|
||||
@@ -145,8 +144,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 wrapper that owns per-instance state and the synchronous read cache
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — driver `ISubscribable` → actor feed (replaces the v1 `DriverSubscriptionBridge`)
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production Roslyn evaluator wired into the actor
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — row projection + engine instantiation (`Phase7Plan` composer)
|
||||
- `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-vs-virtual dispatch kernel
|
||||
|
||||
@@ -136,9 +136,10 @@ ConditionType events (non-base `BaseEventType`) is not verified.
|
||||
## 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:
|
||||
|
||||
@@ -165,6 +166,6 @@ Beyond that:
|
||||
mocked `Session`
|
||||
- `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
|
||||
- `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)
|
||||
|
||||
716
docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md
Normal file
716
docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md
Normal file
@@ -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
|
||||
}
|
||||
@@ -109,7 +109,7 @@ 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`). |
|
||||
| UserName/Password | `LdapOpcUaUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, backed by `LdapAuthService` at `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.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. |
|
||||
|
||||
### LDAP bind flow (`LdapUserAuthenticator`)
|
||||
@@ -221,20 +221,16 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces
|
||||
|
||||
`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.
|
||||
|
||||
### 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(session, operation, scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) bridges the OPC UA stack's `ISystemContext.UserIdentity` to the trie. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call. A non-allow decision short-circuits the dispatch with `BadUserAccessDenied`.
|
||||
|
||||
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`.
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator.
|
||||
- **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.
|
||||
- **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).
|
||||
@@ -249,7 +245,7 @@ Per decision #150 control-plane roles are **deliberately independent of data-pla
|
||||
|
||||
### 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:
|
||||
|
||||
| Role | Capabilities |
|
||||
|---|---|
|
||||
@@ -257,15 +253,7 @@ Per decision #150 control-plane roles are **deliberately independent of data-pla
|
||||
| `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. |
|
||||
|
||||
Policies registered in Admin `Program.cs`:
|
||||
|
||||
```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`) and Razor pages gate inline with the role names, e.g. `@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]` on `Deployments.razor`. Nav-menu sections hide via `<AuthorizeView>`.
|
||||
|
||||
### Role grant source
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -30,5 +30,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
||||
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// </summary>
|
||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
||||
/// <paramref name="parentFolderNodeId"/> (or the namespace root when null). Created with
|
||||
/// Bad quality + null value; subsequent <see cref="WriteValue"/> calls update both.
|
||||
/// Used by <c>Phase7Applier</c> to materialise Galaxy / SystemPlatform tags ahead of any
|
||||
/// driver-side subscribe so OPC UA clients can browse them. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="dataType">OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType);
|
||||
|
||||
/// <summary>
|
||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||
/// successful deployment apply so the node manager reflects the new config. Idempotent.
|
||||
@@ -42,5 +52,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/nav-state.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@* Minimal layout for the login page: no side rail, no brand block. The page
|
||||
renders its own centred card. Mirrors ScadaLink CentralUI's LoginLayout. *@
|
||||
@Body
|
||||
@@ -1,24 +1,9 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<header class="app-bar">
|
||||
<span class="brand"><span class="mark">▮</span> OtOpcUa</span>
|
||||
<span class="crumb">›</span>
|
||||
<span class="crumb">admin console</span>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<span class="meta">@context.User.Identity?.Name</span>
|
||||
<span class="conn-pill" data-state="connected">
|
||||
<span class="dot"></span><span>signed in</span>
|
||||
</span>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<span class="conn-pill" data-state="disconnected">
|
||||
<span class="dot"></span><span>signed out</span>
|
||||
</span>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</header>
|
||||
@* Layout chrome ported from ScadaLink CentralUI: no separate top bar — brand sits
|
||||
at the top of the side rail. The sidebar itself is the interactive island
|
||||
(<NavSidebar/>); MainLayout stays statically rendered so the Body RenderFragment
|
||||
doesn't have to cross an interactive boundary. *@
|
||||
|
||||
<div class="app-shell d-flex flex-column flex-lg-row">
|
||||
@* Hamburger toggle: visible only on viewports <lg.
|
||||
@@ -34,47 +19,7 @@
|
||||
</button>
|
||||
|
||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||
<nav class="side-rail">
|
||||
<div class="rail-eyebrow">Navigation</div>
|
||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
|
||||
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
|
||||
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
|
||||
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
|
||||
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
|
||||
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
|
||||
<div class="rail-eyebrow">Scripting</div>
|
||||
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
||||
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
||||
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
||||
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
||||
|
||||
<div class="rail-eyebrow">Live</div>
|
||||
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
||||
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
||||
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
||||
|
||||
<div class="rail-foot">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
||||
<div class="rail-roles">
|
||||
@string.Join(", ", context.User.Claims
|
||||
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||
</div>
|
||||
<form method="post" action="/auth/logout">
|
||||
<AntiforgeryToken />
|
||||
<button class="rail-btn" type="submit">Sign out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-btn" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</nav>
|
||||
<NavSidebar />
|
||||
</div>
|
||||
|
||||
<main class="page">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
@* A collapsible sidebar nav section: an uppercase-eyebrow button that toggles
|
||||
the visibility of its child nav items. Mirrors the ScadaLink NavSection at
|
||||
/Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
|
||||
but uses OtOpcUa's rail-eyebrow + rail-link classes. *@
|
||||
|
||||
<button type="button"
|
||||
class="rail-eyebrow-toggle"
|
||||
@onclick="OnToggle"
|
||||
aria-expanded="@(Expanded ? "true" : "false")">
|
||||
<span class="rail-eyebrow-chevron">@(Expanded ? "▼" : "▶")</span>
|
||||
<span class="rail-eyebrow-label">@Title</span>
|
||||
</button>
|
||||
@if (Expanded)
|
||||
{
|
||||
<div class="rail-section-body">
|
||||
@ChildContent
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Section label shown in the eyebrow (e.g. "Scripting").</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the section is expanded — its child links rendered.</summary>
|
||||
[Parameter]
|
||||
public bool Expanded { get; set; }
|
||||
|
||||
/// <summary>Raised when the eyebrow button is clicked.</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnToggle { get; set; }
|
||||
|
||||
/// <summary>The section's child nav links, rendered only while expanded.</summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
@rendermode InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.JSInterop
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@* Interactive sidebar — extracted from MainLayout so the layout itself can stay
|
||||
statically rendered (layouts can't take RenderFragment Body across an interactive
|
||||
boundary). Hosts the collapsible NavSection groups and cookie persistence. *@
|
||||
|
||||
<nav class="side-rail">
|
||||
<div class="brand"><span class="mark">▮</span> OtOpcUa</div>
|
||||
|
||||
<NavSection Title="Navigation"
|
||||
Expanded="@_expanded.Contains("nav")"
|
||||
OnToggle="@(() => ToggleAsync("nav"))">
|
||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
|
||||
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
|
||||
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
|
||||
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
|
||||
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
|
||||
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
|
||||
</NavSection>
|
||||
|
||||
<NavSection Title="Scripting"
|
||||
Expanded="@_expanded.Contains("scripting")"
|
||||
OnToggle="@(() => ToggleAsync("scripting"))">
|
||||
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
||||
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
||||
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
||||
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
||||
</NavSection>
|
||||
|
||||
<NavSection Title="Live"
|
||||
Expanded="@_expanded.Contains("live")"
|
||||
OnToggle="@(() => ToggleAsync("live"))">
|
||||
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
||||
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
||||
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
||||
</NavSection>
|
||||
|
||||
<div class="rail-foot">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
||||
<div class="rail-roles">
|
||||
@string.Join(", ", context.User.Claims
|
||||
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||
</div>
|
||||
<form method="post" action="/auth/logout">
|
||||
<AntiforgeryToken />
|
||||
<button class="rail-btn" type="submit">Sign out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-btn" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
// Expanded-section state persists in the `otopcua_nav` cookie via
|
||||
// wwwroot/js/nav-state.js (window.navState.get/.set). Same pattern as
|
||||
// ScadaLink CentralUI's NavMenu.
|
||||
|
||||
private static readonly string[] SectionIds = { "nav", "scripting", "live" };
|
||||
|
||||
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
// Seed from the URL so the current page's section is expanded on the
|
||||
// initial render — works even before JS interop is ready.
|
||||
EnsureCurrentSectionExpanded();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
|
||||
string saved;
|
||||
try
|
||||
{
|
||||
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
||||
}
|
||||
catch (JSDisconnectedException) { return; }
|
||||
catch (InvalidOperationException) { return; }
|
||||
|
||||
foreach (var id in saved.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (Array.IndexOf(SectionIds, id) >= 0)
|
||||
_expanded.Add(id);
|
||||
}
|
||||
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
await PersistAsync();
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
{
|
||||
_ = PersistAsync();
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAsync(string id)
|
||||
{
|
||||
if (!_expanded.Remove(id))
|
||||
_expanded.Add(id);
|
||||
await PersistAsync();
|
||||
}
|
||||
|
||||
private bool EnsureCurrentSectionExpanded()
|
||||
{
|
||||
var section = CurrentSection();
|
||||
return section is not null && _expanded.Add(section);
|
||||
}
|
||||
|
||||
private string? CurrentSection()
|
||||
{
|
||||
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||
var firstSegment = relative.Split('?', '#')[0]
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault();
|
||||
|
||||
return firstSegment switch
|
||||
{
|
||||
null or "" => "nav",
|
||||
"fleet" or "hosts" or "clusters" or "reservations" or "certificates" or "role-grants" => "nav",
|
||||
"virtual-tags" or "scripted-alarms" or "scripts" or "script-log" => "scripting",
|
||||
"deployments" or "alerts" or "alarms-historian" => "live",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PersistAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
||||
}
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
@page "/alerts"
|
||||
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
|
||||
AlarmTransitionEvent entries. Engine wiring (ScriptedAlarmActor publish on the `alerts`
|
||||
topic) lands with F9; until then the connection stays open and the table is empty. *@
|
||||
AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
|
||||
and the AB CIP ALMD bridge. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@@ -23,14 +23,14 @@
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
|
||||
the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
|
||||
ScriptedAlarmActor, native AB CIP ALMD bridge (F9), Galaxy alarm bridge (future).
|
||||
ScriptedAlarmActor, native driver alarm bridges (AB CIP ALMD, Galaxy where wired).
|
||||
</section>
|
||||
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.08s">
|
||||
No alarms yet. Engine wiring (F9 ScriptedAlarmActor) is pending; once it ships the table
|
||||
below will start populating in real time.
|
||||
No alarms in the current window. The table will populate as soon as a
|
||||
ScriptedAlarmActor or driver alarm bridge publishes a transition.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
@page "/login"
|
||||
@layout LoginLayout
|
||||
@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy
|
||||
would lock operators out of the only way in (Admin-001). Static-rendered on purpose:
|
||||
the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response.
|
||||
Calling SignInAsync from an interactive circuit would be too late. *@
|
||||
Calling SignInAsync from an interactive circuit would be too late.
|
||||
|
||||
Uses LoginLayout (no side rail) so the page renders as a clean centred card. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
|
||||
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||
<section class="panel">
|
||||
<div class="panel-head">OtOpcUa Admin — sign in</div>
|
||||
<div style="padding:1.1rem 1.1rem 1.25rem">
|
||||
<div style="padding:1.4rem 1.1rem 1.25rem">
|
||||
<h1 class="login-title">OtOpcUa Admin — sign in</h1>
|
||||
<form method="post" action="/auth/login" data-enhance="false">
|
||||
@if (ReturnUrl is not null)
|
||||
{
|
||||
@@ -32,12 +35,6 @@
|
||||
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
|
||||
font-size:.78rem;color:var(--ink-faint)">
|
||||
LDAP bind against the configured directory (per Q5 of the AdminUI rebuild plan:
|
||||
generic error in production; specific reason when <span class="mono">Authentication:Ldap:AllowInsecureLdap=true</span>).
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,9 @@ public static class EndpointRouteBuilderExtensions
|
||||
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
|
||||
where TApp : IComponent
|
||||
{
|
||||
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||
// UseAuthentication() — see Program.cs.
|
||||
app.MapRazorComponents<TApp>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
return app;
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
|
||||
|
||||
@@ -49,6 +49,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Login card title. Replaces the panel-head top strip on the login page so the
|
||||
card reads as a self-contained sign-in form, not a tabbed panel. */
|
||||
.login-title {
|
||||
margin: 0 0 1.1rem 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Brand block pinned at the top of the side rail. Mirrors ScadaLink's
|
||||
.sidebar .brand styling — used now that the top app-bar was dropped. */
|
||||
.side-rail .brand {
|
||||
color: var(--ink);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.side-rail .brand .mark { color: var(--accent); }
|
||||
|
||||
.rail-eyebrow {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
@@ -58,6 +81,36 @@
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Collapsible variant — rendered by NavSection.razor. Looks like .rail-eyebrow
|
||||
plus a leading chevron; clicking flips chevron + expanded state. */
|
||||
.rail-eyebrow-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--ink-faint);
|
||||
padding: 0.45rem 0.6rem 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-eyebrow-toggle:hover { color: var(--ink); }
|
||||
.rail-eyebrow-chevron {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
font-size: 0.55rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.rail-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rail-link {
|
||||
display: block;
|
||||
padding: 0.4rem 0.6rem;
|
||||
|
||||
19
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js
Normal file
19
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Sidebar nav collapse state — persisted in the `otopcua_nav` cookie so it
|
||||
// survives full page reloads and reconnects. Invoked from MainLayout.razor via
|
||||
// JS interop (window.navState.get / .set). Mirrors the ScadaLink pattern at
|
||||
// /Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js.
|
||||
window.navState = {
|
||||
// Returns the raw cookie value (comma-separated expanded section ids), or
|
||||
// an empty string when the cookie is absent.
|
||||
get: function () {
|
||||
const match = document.cookie.match(/(?:^|;\s*)otopcua_nav=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
},
|
||||
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
|
||||
// (JS must write it) and not sensitive.
|
||||
set: function (value) {
|
||||
const oneYearSeconds = 60 * 60 * 24 * 365;
|
||||
document.cookie = "otopcua_nav=" + encodeURIComponent(value) +
|
||||
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
|
||||
}
|
||||
};
|
||||
@@ -24,18 +24,21 @@ public static class HealthEndpoints
|
||||
|
||||
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// AllowAnonymous on all three — Traefik / k8s liveness probes / load-balancers
|
||||
// hit these without credentials. Without it the AddOtOpcUaAuth fallback policy
|
||||
// 401s every probe and Traefik marks every backend unhealthy.
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("ready"),
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
app.MapHealthChecks("/health/active", new HealthCheckOptions
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("active"),
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => false, // process-liveness only — no probes run.
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ var hasDriver = roles.Contains("driver");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Razor class library static assets (_content/<libname>/...) only auto-enable in
|
||||
// the Development environment. Opt in explicitly so the AdminUI's CSS/JS works
|
||||
// regardless of ASPNETCORE_ENVIRONMENT.
|
||||
builder.WebHost.UseStaticWebAssets();
|
||||
|
||||
// Windows Service support: when the EXE is started by Service Control Manager (sc.exe),
|
||||
// the host needs to call SetServiceStatus to keep the SCM happy. UseWindowsService()
|
||||
// installs the WindowsServiceLifetime IFF WindowsServiceHelpers.IsWindowsService() is
|
||||
// true at runtime — so it's safely a no-op when running as a console app or on Linux.
|
||||
builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost");
|
||||
|
||||
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
||||
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
|
||||
var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
||||
@@ -111,6 +122,9 @@ if (hasAdmin)
|
||||
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
||||
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
||||
builder.Services.AddAdminUI();
|
||||
// Flow AuthenticationState through cascading parameters so <AuthorizeView/> works
|
||||
// inside interactive components (NavSidebar's session block).
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddOtOpcUaAdminClients();
|
||||
}
|
||||
@@ -121,6 +135,12 @@ builder.Services.AddOtOpcUaObservability();
|
||||
var app = builder.Build();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
// Razor class library static assets (_content/<libname>/...) are served via endpoint
|
||||
// routing, NOT the UseStaticFiles middleware — so we MUST mark the static-asset
|
||||
// endpoints AllowAnonymous, otherwise the AddOtOpcUaAuth fallback RequireAuthenticatedUser
|
||||
// policy 401s every CSS/JS request and the login page renders unstyled.
|
||||
app.MapStaticAssets().AllowAnonymous();
|
||||
|
||||
if (hasAdmin)
|
||||
{
|
||||
app.UseAuthentication();
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.Hosting"/>
|
||||
<PackageReference Include="Serilog.AspNetCore"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Condition="$([MSBuild]::IsOSPlatform('Windows'))"/>
|
||||
<!-- Always referenced (drops earlier IsOSPlatform condition) so Program.cs can
|
||||
call builder.Host.UseWindowsService() unconditionally; the runtime helper
|
||||
only activates when actually running as a Windows Service, so this is a
|
||||
no-op on macOS/Linux. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Opc.Ua": "Information",
|
||||
"Akka": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json
Normal file
16
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Akka": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json
Normal file
16
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Opc.Ua": "Debug",
|
||||
"Akka": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,15 @@ public sealed class OpcUaApplicationHostOptions
|
||||
/// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
|
||||
/// </summary>
|
||||
public bool AutoAcceptUntrustedClientCertificates { get; set; }
|
||||
|
||||
/// <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>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -112,6 +121,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
await _application.Start(server).ConfigureAwait(false);
|
||||
|
||||
AttachUserAuthenticator();
|
||||
PopulateServerArray();
|
||||
|
||||
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
||||
_options.PublicHostname, _options.OpcUaPort);
|
||||
@@ -143,6 +153,60 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
sessionManager.ImpersonateUser += _impersonateHandler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> via the OPC UA
|
||||
/// standard <c>Server.ServerArray</c> property (NodeId i=2254) so warm-redundancy clients
|
||||
/// can discover the partner endpoint.
|
||||
///
|
||||
/// The wire-served value of <c>Server.ServerArray</c> comes from
|
||||
/// <see cref="IServerInternal.ServerUris"/> (an <see cref="Opc.Ua.StringTable"/>) via the
|
||||
/// SDK's <c>OnReadServerArray</c> callback — writes to
|
||||
/// <c>ServerObject.ServerArray.Value</c> are NOT what clients read. The SDK auto-populates
|
||||
/// slot 0 with the local <c>ApplicationUri</c> on <c>ApplicationInstance.Start</c>; we
|
||||
/// append the configured peers at slots 1, 2, … here.
|
||||
///
|
||||
/// The address-space property is also mirrored for in-process readers (the unit-test
|
||||
/// observation seam) and as a defensive belt-and-braces measure.
|
||||
/// </summary>
|
||||
private void PopulateServerArray()
|
||||
{
|
||||
var internalData = _server?.CurrentInstance;
|
||||
if (internalData is null) return;
|
||||
|
||||
// Wire path: append peers to IServerInternal.ServerUris — this is what
|
||||
// OnReadServerArray serves to remote clients reading VariableIds.Server_ServerArray.
|
||||
var serverUris = internalData.ServerUris;
|
||||
var existing = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (uint i = 0; i < (uint)serverUris.Count; i++)
|
||||
{
|
||||
var existingUri = serverUris.GetString(i);
|
||||
if (existingUri is not null) existing.Add(existingUri);
|
||||
}
|
||||
|
||||
foreach (var peer in _options.PeerApplicationUris)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(peer)) continue;
|
||||
if (existing.Contains(peer)) continue;
|
||||
serverUris.Append(peer);
|
||||
existing.Add(peer);
|
||||
}
|
||||
|
||||
// In-process mirror: ServerObject.ServerArray.Value is consulted by some tests and
|
||||
// tooling that read the SDK's address-space model directly rather than going through
|
||||
// a session. Harmless on the wire (the SDK ignores it) but useful in-VM.
|
||||
var serverObject = internalData.ServerObject;
|
||||
if (serverObject is not null)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session session, ImpersonateEventArgs args) =>
|
||||
HandleImpersonation(_userAuthenticator, args, _logger);
|
||||
|
||||
|
||||
@@ -109,6 +109,69 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
|
||||
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
|
||||
/// timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
|
||||
/// Idempotent. Materialises Galaxy / SystemPlatform tags so they're browseable before the
|
||||
/// Galaxy driver issues SubscribeBulk.
|
||||
/// </summary>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||
|
||||
// If already present, leave it alone (idempotent re-applies).
|
||||
if (_variables.ContainsKey(variableNodeId)) return;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_variables.ContainsKey(variableNodeId)) return;
|
||||
|
||||
var parent = ResolveParentFolder(parentFolderNodeId);
|
||||
var variable = new BaseDataVariableState(parent)
|
||||
{
|
||||
NodeId = new NodeId(variableNodeId, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(variableNodeId, NamespaceIndex),
|
||||
DisplayName = displayName,
|
||||
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||
DataType = ResolveBuiltInDataType(dataType),
|
||||
ValueRank = ValueRanks.Scalar,
|
||||
AccessLevel = AccessLevels.CurrentRead,
|
||||
UserAccessLevel = AccessLevels.CurrentRead,
|
||||
Historizing = false,
|
||||
Value = null,
|
||||
StatusCode = StatusCodes.BadWaitingForInitialData,
|
||||
Timestamp = DateTime.MinValue,
|
||||
};
|
||||
parent.AddChild(variable);
|
||||
AddPredefinedNode(SystemContext, variable);
|
||||
_variables[variableNodeId] = variable;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
|
||||
/// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
|
||||
/// (matches CreateVariable's default for lazy-created nodes).</summary>
|
||||
private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
|
||||
{
|
||||
"Boolean" => DataTypeIds.Boolean,
|
||||
"SByte" => DataTypeIds.SByte,
|
||||
"Byte" => DataTypeIds.Byte,
|
||||
"Int16" => DataTypeIds.Int16,
|
||||
"UInt16" => DataTypeIds.UInt16,
|
||||
"Int32" => DataTypeIds.Int32,
|
||||
"UInt32" => DataTypeIds.UInt32,
|
||||
"Int64" => DataTypeIds.Int64,
|
||||
"UInt64" => DataTypeIds.UInt64,
|
||||
"Float" => DataTypeIds.Float,
|
||||
"Double" => DataTypeIds.Double,
|
||||
"String" => DataTypeIds.String,
|
||||
"DateTime" => DataTypeIds.DateTime,
|
||||
_ => DataTypeIds.BaseDataType,
|
||||
};
|
||||
|
||||
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
|
||||
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
||||
/// EnsureFolder + WriteValue on the next pass.</summary>
|
||||
|
||||
@@ -64,16 +64,19 @@ public sealed class Phase7Applier
|
||||
}
|
||||
|
||||
var changedCount =
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count;
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||
plan.ChangedGalaxyTags.Count;
|
||||
var addedCount =
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count;
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedGalaxyTags.Count;
|
||||
|
||||
// Any add/remove of Equipment or ScriptedAlarm requires a real address-space rebuild.
|
||||
// Driver-instance changes don't touch the address-space topology directly — they go
|
||||
// through DriverHostActor's spawn-plan in Runtime.
|
||||
// Any add/remove of Equipment, ScriptedAlarm, or Galaxy tag topology requires a real
|
||||
// address-space rebuild. Driver-instance changes don't touch the address-space topology
|
||||
// directly — they go through DriverHostActor's spawn-plan in Runtime.
|
||||
var needsRebuild =
|
||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0;
|
||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
|
||||
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0;
|
||||
|
||||
if (needsRebuild)
|
||||
{
|
||||
@@ -125,12 +128,55 @@ public sealed class Phase7Applier
|
||||
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot:
|
||||
/// for each <see cref="GalaxyTagPlan"/>, ensure its FolderPath segment exists (a folder
|
||||
/// under the namespace root), then ensure a Variable node sits inside that folder for
|
||||
/// the leaf <see cref="GalaxyTagPlan.DisplayName"/>. Variable starts with BadWaitingForInitialData;
|
||||
/// the Galaxy driver's <c>OnDataChange</c> path fills the value in once SubscribeBulk lands.
|
||||
/// Idempotent.
|
||||
/// </summary>
|
||||
public void MaterialiseGalaxyTags(Phase7CompositionResult composition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.GalaxyTags.Count == 0) return;
|
||||
|
||||
// Folders first — each distinct FolderPath becomes one folder under the root.
|
||||
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var tag in composition.GalaxyTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||
if (!foldersCreated.Add(tag.FolderPath)) continue;
|
||||
SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath);
|
||||
}
|
||||
|
||||
// Variables: NodeId is "<FolderPath>.<DisplayName>" so it matches the MXAccess ref the
|
||||
// Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical
|
||||
// resolution; flat NodeId keeps the address space lookup cheap.
|
||||
foreach (var tag in composition.GalaxyTags)
|
||||
{
|
||||
var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef;
|
||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath;
|
||||
SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})",
|
||||
composition.GalaxyTags.Count, foldersCreated.Count);
|
||||
}
|
||||
|
||||
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||
{
|
||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType)
|
||||
{
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
||||
{
|
||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
|
||||
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
|
||||
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
|
||||
/// its parent line id so the applier knows where to hang each equipment folder.</summary>
|
||||
/// its parent line id so the applier knows where to hang each equipment folder.
|
||||
/// <see cref="GalaxyTags"/> carries SystemPlatform-namespace tags (Galaxy hierarchy) so the
|
||||
/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe.</summary>
|
||||
public sealed record Phase7CompositionResult(
|
||||
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
||||
IReadOnlyList<UnsLineProjection> UnsLines,
|
||||
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
||||
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
||||
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
|
||||
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans,
|
||||
IReadOnlyList<GalaxyTagPlan> GalaxyTags)
|
||||
{
|
||||
/// <summary>Convenience constructor for tests + earlier callers that don't yet carry UNS topology.</summary>
|
||||
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data.</summary>
|
||||
public Phase7CompositionResult(
|
||||
IReadOnlyList<EquipmentNode> equipmentNodes,
|
||||
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
||||
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
||||
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
||||
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
|
||||
equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
|
||||
public Phase7CompositionResult(
|
||||
IReadOnlyList<UnsAreaProjection> unsAreas,
|
||||
IReadOnlyList<UnsLineProjection> unsLines,
|
||||
IReadOnlyList<EquipmentNode> equipmentNodes,
|
||||
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
||||
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
||||
: this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -30,6 +45,21 @@ public sealed record EquipmentNode(string EquipmentId, string DisplayName, strin
|
||||
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
||||
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
||||
|
||||
/// <summary>
|
||||
/// One Galaxy / SystemPlatform-namespace tag from a <see cref="Tag"/> row where
|
||||
/// <see cref="Tag.EquipmentId"/> is null. Carries the FolderPath segment that the applier
|
||||
/// turns into a folder, the leaf <see cref="DisplayName"/> for the Variable, the OPC UA
|
||||
/// <see cref="DataType"/>, and the dot-form MXAccess reference (<see cref="MxAccessRef"/>)
|
||||
/// that the Galaxy driver consumes when subscribing.
|
||||
/// </summary>
|
||||
public sealed record GalaxyTagPlan(
|
||||
string TagId,
|
||||
string DriverInstanceId,
|
||||
string FolderPath,
|
||||
string DisplayName,
|
||||
string DataType,
|
||||
string MxAccessRef);
|
||||
|
||||
/// <summary>
|
||||
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
||||
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
||||
@@ -43,19 +73,32 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
|
||||
/// </summary>
|
||||
public static class Phase7Composer
|
||||
{
|
||||
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
|
||||
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</summary>
|
||||
public static Phase7CompositionResult Compose(
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
|
||||
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
||||
|
||||
/// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</summary>
|
||||
public static Phase7CompositionResult Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
||||
|
||||
public static Phase7CompositionResult Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||
IReadOnlyList<Tag> tags,
|
||||
IReadOnlyList<Namespace> namespaces)
|
||||
{
|
||||
var areas = unsAreas
|
||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
@@ -82,6 +125,30 @@ public static class Phase7Composer
|
||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||
.ToList();
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms);
|
||||
// SystemPlatform tags = Galaxy tags. Match each tag to its DriverInstance and that
|
||||
// driver's Namespace; emit only when the namespace kind is SystemPlatform AND the tag
|
||||
// has no EquipmentId (per the entity invariant for SystemPlatform).
|
||||
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
||||
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
|
||||
|
||||
var galaxyTags = tags
|
||||
.Where(t => t.EquipmentId is null)
|
||||
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
|
||||
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
|
||||
&& ns.Kind == NamespaceKind.SystemPlatform)
|
||||
.OrderBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ThenBy(t => t.FolderPath, StringComparer.Ordinal)
|
||||
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
||||
.Select(t => new GalaxyTagPlan(
|
||||
TagId: t.TagId,
|
||||
DriverInstanceId: t.DriverInstanceId,
|
||||
FolderPath: t.FolderPath ?? string.Empty,
|
||||
DisplayName: t.Name,
|
||||
DataType: t.DataType,
|
||||
// MXAccess reference: "FolderPath.Name" when FolderPath is set, else just "Name".
|
||||
MxAccessRef: string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}"))
|
||||
.ToList();
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,21 @@ public sealed record Phase7Plan(
|
||||
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
|
||||
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
||||
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
||||
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms)
|
||||
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms,
|
||||
IReadOnlyList<GalaxyTagPlan> AddedGalaxyTags,
|
||||
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
|
||||
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
|
||||
{
|
||||
public bool IsEmpty =>
|
||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
|
||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0;
|
||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
||||
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0;
|
||||
|
||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
||||
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
||||
public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current);
|
||||
}
|
||||
|
||||
public static class Phase7Planner
|
||||
@@ -61,10 +66,16 @@ public static class Phase7Planner
|
||||
a => a.ScriptedAlarmId,
|
||||
(a, b) => new Phase7Plan.AlarmDelta(a, b));
|
||||
|
||||
var (addedGalaxy, removedGalaxy, changedGalaxy) = DiffById(
|
||||
previous.GalaxyTags, next.GalaxyTags,
|
||||
t => t.TagId,
|
||||
(a, b) => new Phase7Plan.GalaxyTagDelta(a, b));
|
||||
|
||||
return new Phase7Plan(
|
||||
addedEq, removedEq, changedEq,
|
||||
addedDrv, removedDrv, changedDrv,
|
||||
addedAlarm, removedAlarm, changedAlarm);
|
||||
addedAlarm, removedAlarm, changedAlarm,
|
||||
addedGalaxy, removedGalaxy, changedGalaxy);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
|
||||
|
||||
@@ -27,5 +27,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
||||
|
||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||
}
|
||||
|
||||
@@ -101,8 +101,9 @@ public static class DeploymentArtifact
|
||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms);
|
||||
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -115,7 +116,87 @@ public static class DeploymentArtifact
|
||||
Array.Empty<UnsLineProjection>(),
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
Array.Empty<ScriptedAlarmPlan>(),
|
||||
Array.Empty<GalaxyTagPlan>());
|
||||
|
||||
/// <summary>
|
||||
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
|
||||
/// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one <see cref="GalaxyTagPlan"/>
|
||||
/// per qualifying tag. Mirrors <c>Phase7Composer.Compose</c>'s filter so a compose-side
|
||||
/// plan and an artifact-decode plan agree on the same set of tags.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<GalaxyTagPlan> BuildGalaxyTagPlans(JsonElement root, IReadOnlyList<DriverInstancePlan> drivers)
|
||||
{
|
||||
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<GalaxyTagPlan>();
|
||||
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<GalaxyTagPlan>();
|
||||
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<GalaxyTagPlan>();
|
||||
|
||||
// namespaceId → Kind ("SystemPlatform"/"Equipment"/"Simulated") — enum serialises as int by default,
|
||||
// but ConfigComposer's snapshot uses default JsonSerializer which writes numbers. Tolerate both.
|
||||
var systemPlatformNamespaces = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var el in nsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
if (!el.TryGetProperty("Kind", out var kindEl)) continue;
|
||||
var isSystemPlatform = kindEl.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => kindEl.GetInt32() == 1, // NamespaceKind.SystemPlatform = 1
|
||||
JsonValueKind.String => string.Equals(kindEl.GetString(), "SystemPlatform", StringComparison.Ordinal),
|
||||
_ => false,
|
||||
};
|
||||
if (isSystemPlatform) systemPlatformNamespaces.Add(id!);
|
||||
}
|
||||
|
||||
// driverInstanceId → namespaceId
|
||||
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var el in diArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
|
||||
driverToNamespace[id!] = ns!;
|
||||
}
|
||||
|
||||
var result = new List<GalaxyTagPlan>(tagsArr.GetArrayLength());
|
||||
foreach (var el in tagsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
// Skip tags with non-null EquipmentId (Equipment-namespace tags belong to a different path).
|
||||
if (el.TryGetProperty("EquipmentId", out var eqEl) && eqEl.ValueKind != JsonValueKind.Null) continue;
|
||||
|
||||
var tagId = el.TryGetProperty("TagId", out var tEl) ? tEl.GetString() : null;
|
||||
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
|
||||
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
|
||||
? fpEl.GetString() : null;
|
||||
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
||||
if (!systemPlatformNamespaces.Contains(nsId)) continue;
|
||||
|
||||
var folderPath = folder ?? string.Empty;
|
||||
var mxRef = string.IsNullOrWhiteSpace(folderPath) ? name! : $"{folderPath}.{name}";
|
||||
result.Add(new GalaxyTagPlan(tagId!, di!, folderPath, name!, dataType ?? "BaseDataType", mxRef));
|
||||
}
|
||||
|
||||
result.Sort((a, b) =>
|
||||
{
|
||||
var byDriver = string.CompareOrdinal(a.DriverInstanceId, b.DriverInstanceId);
|
||||
if (byDriver != 0) return byDriver;
|
||||
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
|
||||
if (byFolder != 0) return byFolder;
|
||||
return string.CompareOrdinal(a.DisplayName, b.DisplayName);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||
where T : class
|
||||
|
||||
@@ -66,9 +66,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||
/// configured roles. Mirrors plan §Task 55: Windows-only driver types (Galaxy, Wonderware
|
||||
/// Historian) are stubbed when running on non-Windows OR when the host carries the
|
||||
/// <c>dev</c> role.
|
||||
/// configured roles. Only the v1 in-process types stay Windows-only:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"Galaxy"</c> — legacy MXAccess COM proxy (retired in PR 7.2; gated for any
|
||||
/// leftover DriverInstance rows that still reference the old type name).</item>
|
||||
/// <item><c>"Historian.Wonderware"</c> — Wonderware Historian sidecar over Windows-only
|
||||
/// named pipes.</item>
|
||||
/// </list>
|
||||
/// The v2 <c>"GalaxyMxGateway"</c> driver talks gRPC to an external mxaccessgw process,
|
||||
/// so it runs on any platform .NET 10 supports — Linux containers included. Not stubbed.
|
||||
/// </summary>
|
||||
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
|
||||
{
|
||||
|
||||
@@ -45,9 +45,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
private int _writes;
|
||||
private byte _lastServiceLevel;
|
||||
private Phase7CompositionResult _lastApplied = new(
|
||||
Array.Empty<UnsAreaProjection>(),
|
||||
Array.Empty<UnsLineProjection>(),
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
Array.Empty<ScriptedAlarmPlan>(),
|
||||
Array.Empty<GalaxyTagPlan>());
|
||||
|
||||
public int WriteCount => _writes;
|
||||
public byte LastServiceLevel => _lastServiceLevel;
|
||||
@@ -190,6 +193,10 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
||||
// skips folders that already exist with the same node id.
|
||||
_applier.MaterialiseHierarchy(composition);
|
||||
// Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder
|
||||
// + Variable node exist so clients can browse them. The Galaxy driver fills values
|
||||
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
|
||||
_applier.MaterialiseGalaxyTags(composition);
|
||||
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -12,13 +13,20 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
/// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
|
||||
public sealed record LoginRequest(string Username, string Password);
|
||||
|
||||
public sealed record TokenResponse(string Token);
|
||||
|
||||
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous();
|
||||
// The login endpoint serves two callers with different ergonomics:
|
||||
// - Browser form POST (application/x-www-form-urlencoded) → redirect dance
|
||||
// - API JSON POST (application/json) → 204 / 401 / 503 status codes
|
||||
// DisableAntiforgery: the login form is the entry point — anonymous by definition,
|
||||
// no prior session, so XSRF doesn't apply. AllowAnonymous: override the
|
||||
// AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
|
||||
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
|
||||
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
||||
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
||||
@@ -26,15 +34,35 @@ public static class AuthEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest request,
|
||||
HttpContext http,
|
||||
ILdapAuthService ldap,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var isForm = http.Request.HasFormContentType;
|
||||
string username, password, returnUrl;
|
||||
|
||||
if (isForm)
|
||||
{
|
||||
var form = await http.Request.ReadFormAsync(ct);
|
||||
username = form["username"].ToString();
|
||||
password = form["password"].ToString();
|
||||
returnUrl = form["returnUrl"].ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<LoginRequest>(
|
||||
http.Request.Body,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web),
|
||||
ct);
|
||||
username = body?.Username ?? string.Empty;
|
||||
password = body?.Password ?? string.Empty;
|
||||
returnUrl = string.Empty;
|
||||
}
|
||||
|
||||
LdapAuthResult result;
|
||||
try
|
||||
{
|
||||
result = await ldap.AuthenticateAsync(request.Username, request.Password, ct);
|
||||
result = await ldap.AuthenticateAsync(username, password, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -42,13 +70,20 @@ public static class AuthEndpoints
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
return Results.Unauthorized();
|
||||
{
|
||||
if (!isForm) return Results.Unauthorized();
|
||||
|
||||
var qs = $"?error={Uri.EscapeDataString(result.Error ?? "Invalid credentials")}";
|
||||
if (!string.IsNullOrWhiteSpace(returnUrl))
|
||||
qs += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Results.Redirect("/login" + qs);
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, result.Username ?? request.Username),
|
||||
new(JwtTokenService.UsernameClaimType, result.Username ?? request.Username),
|
||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? request.Username),
|
||||
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
||||
};
|
||||
foreach (var role in result.Roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
@@ -57,7 +92,9 @@ public static class AuthEndpoints
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
return Results.NoContent();
|
||||
|
||||
if (!isForm) return Results.NoContent();
|
||||
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
||||
}
|
||||
|
||||
private static IResult Ping(HttpContext http) =>
|
||||
@@ -78,6 +115,13 @@ public static class AuthEndpoints
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http)
|
||||
{
|
||||
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Results.NoContent();
|
||||
|
||||
// Browser form POST → redirect to /login so the user lands somewhere visible.
|
||||
// API callers that prefer the status-only contract should hit the endpoint with
|
||||
// Accept: application/json and we'll hand them a 204 instead.
|
||||
var wantsJson = http.Request.Headers.Accept.Any(v =>
|
||||
v?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true);
|
||||
if (wantsJson) return Results.NoContent();
|
||||
return Results.Redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, null, [], [], "Password is required");
|
||||
|
||||
if (_options.DevStubMode)
|
||||
{
|
||||
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||
}
|
||||
|
||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||
|
||||
@@ -17,6 +17,13 @@ public sealed class LdapOptions
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||
public bool AllowInsecureLdap { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
|
||||
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
||||
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
||||
/// </summary>
|
||||
public bool DevStubMode { get; set; }
|
||||
|
||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -43,7 +43,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||
// The driver-branch in Host/Program.cs registers the same way; consistent lifetime
|
||||
// across both paths keeps ValidateScopes-on-Build clean.
|
||||
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
/// Failover scenarios layered on <see cref="TwoNodeClusterHarness"/> Stop/Restart primitives.
|
||||
/// Covers graceful node loss, rejoin on the same Akka port, and deployment under reduced membership.
|
||||
/// </summary>
|
||||
public sealed class FailoverScenarioTests
|
||||
public sealed class FailoverDuringDeployTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ClientSession = Opc.Ua.Client.Session;
|
||||
|
||||
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,
|
||||
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)
|
||||
{
|
||||
// SDK 1.5.374 sync-style session-open path — mirrors src/Client/.../DefaultSessionFactory.cs
|
||||
// and DefaultApplicationConfigurationFactory.cs. The 1.5.378 telemetry/async overloads are
|
||||
// not available at this pinned version.
|
||||
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 },
|
||||
};
|
||||
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 ClientSession.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<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"/>
|
||||
<!-- Pin to 1.5.374.126 to match the server src (OpcUaServer.csproj). Mixing transitive
|
||||
Opc.Ua.Core across 1.5.374 / 1.5.378 produces a MissingMethodException at runtime
|
||||
because ApplicationInstance.Start(ServerBase) (sync) was removed in 1.5.378 in
|
||||
favour of StartAsync. Stays here until Opc.Ua.Server publishes 1.5.378.x. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" VersionOverride="1.5.374.126"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" VersionOverride="1.5.374.126"/>
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user