diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml index 233f048..48d2e6f 100644 --- a/.github/workflows/v2-ci.yml +++ b/.github/workflows/v2-ci.yml @@ -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" diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 124972d..e03cb30 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -63,6 +63,7 @@ + diff --git a/docker-dev/README.md b/docker-dev/README.md index 5f08ac2..4bd15fd 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -1,20 +1,63 @@ # 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` | +| `sql` | SQL Server 2022 — single `OtOpcUa` ConfigDb shared by all three clusters | 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` | +| `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8080` | -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. +### 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:` 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 +``` ## Bring up @@ -22,12 +65,16 @@ 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:8080 # 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) @@ -58,5 +105,8 @@ The `-v` drops the SQL + LDAP volumes; remove it to keep ConfigDb state across r ## 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. diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 4dcc67a..69ba80e 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -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:8080 # Traefik dashboard # # Tear-down: docker compose -f docker-dev/docker-compose.yml down -v @@ -34,6 +57,20 @@ services: timeout: 5s retries: 20 + # ── 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" + ldap: image: bitnami/openldap:2.6 environment: @@ -113,6 +150,103 @@ services: 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__Server: "ldap" + Authentication__Ldap__Port: "1389" + Authentication__Ldap__AllowInsecureLdap: "true" + 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__Server: "ldap" + Authentication__Ldap__Port: "1389" + Authentication__Ldap__AllowInsecureLdap: "true" + 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__Server: "ldap" + Authentication__Ldap__Port: "1389" + Authentication__Ldap__AllowInsecureLdap: "true" + 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__Server: "ldap" + Authentication__Ldap__Port: "1389" + Authentication__Ldap__AllowInsecureLdap: "true" + ports: + - "4845:4840" + traefik: image: traefik:v3.1 command: @@ -128,3 +262,7 @@ services: depends_on: - admin-a - admin-b + - site-a-1 + - site-a-2 + - site-b-1 + - site-b-2 diff --git a/docker-dev/seed/entrypoint.sh b/docker-dev/seed/entrypoint.sh new file mode 100755 index 0000000..d2799ce --- /dev/null +++ b/docker-dev/seed/entrypoint.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# docker-dev cluster-seed entrypoint. Waits for the host containers to finish +# their EF Core auto-migration (which creates the ServerCluster table), then +# applies the idempotent seed script. +# +# Image: mcr.microsoft.com/mssql-tools (Debian + sqlcmd at /opt/mssql-tools18/bin). + +set -euo pipefail + +SQLCMD="/opt/mssql-tools18/bin/sqlcmd" +SERVER="${SQL_HOST:-sql},1433" +USER="${SQL_USER:-sa}" +PASS="${SQL_PASSWORD:-OtOpcUa!Dev123}" +DB="${SQL_DATABASE:-OtOpcUa}" + +run_sql() { + "$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$DB" -No -b -h -1 "$@" +} + +echo "[cluster-seed] waiting for SQL Server to accept connections..." +until run_sql -Q "SELECT 1" >/dev/null 2>&1; do + sleep 2 +done +echo "[cluster-seed] SQL Server up." + +echo "[cluster-seed] waiting for $DB.ServerCluster (host containers must finish EF migration)..." +until run_sql -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..." +run_sql -i /seed/seed-clusters.sql + +echo "[cluster-seed] done." diff --git a/docker-dev/seed/seed-clusters.sql b/docker-dev/seed/seed-clusters.sql new file mode 100644 index 0000000..ff74996 --- /dev/null +++ b/docker-dev/seed/seed-clusters.sql @@ -0,0 +1,106 @@ +-- 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:. +-- +-- 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 +------------------------------------------------------------------------------ + +IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('driver-a', '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') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('driver-b', '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') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-a-1', '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') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-a-2', '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') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-b-1', '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') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-b-2', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed'); + +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; diff --git a/docker-dev/traefik-dynamic.yml b/docker-dev/traefik-dynamic.yml index de51e14..610d0d8 100644 --- a/docker-dev/traefik-dynamic.yml +++ b/docker-dev/traefik-dynamic.yml @@ -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,6 +15,16 @@ 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: @@ -19,3 +35,23 @@ http: path: /health/active interval: 5s timeout: 2s + + otopcua-site-a: + loadBalancer: + 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: + servers: + - url: "http://site-b-1:9000" + - url: "http://site-b-2:9000" + healthCheck: + path: /health/active + interval: 5s + timeout: 2s diff --git a/docs/AddressSpace.md b/docs/AddressSpace.md index 635d1a9..40b22e5 100644 --- a/docs/AddressSpace.md +++ b/docs/AddressSpace.md @@ -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 diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 20781a3..03f03d6 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -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 diff --git a/docs/IncrementalSync.md b/docs/IncrementalSync.md index 9449674..1514302 100644 --- a/docs/IncrementalSync.md +++ b/docs/IncrementalSync.md @@ -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 diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index 89c1645..43f1623 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -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`) diff --git a/docs/README.md b/docs/README.md index c85de84..793cd86 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/ReadWriteOperations.md b/docs/ReadWriteOperations.md index 9956ee8..163931d 100644 --- a/docs/ReadWriteOperations.md +++ b/docs/ReadWriteOperations.md @@ -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` diff --git a/docs/Redundancy.md b/docs/Redundancy.md index fbab890..1be82f5 100644 --- a/docs/Redundancy.md +++ b/docs/Redundancy.md @@ -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`, 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`. diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md index 1277ba6..fe2ccdc 100644 --- a/docs/ScriptedAlarms.md +++ b/docs/ScriptedAlarms.md @@ -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 diff --git a/docs/ServiceHosting.md b/docs/ServiceHosting.md index df0ff98..eb0cc5f 100644 --- a/docs/ServiceHosting.md +++ b/docs/ServiceHosting.md @@ -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`: diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index 5c68043..6880ff4 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -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` 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 diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md index 9b7b9d8..a05b276 100644 --- a/docs/drivers/OpcUaClient-Test-Fixture.md +++ b/docs/drivers/OpcUaClient-Test-Fixture.md @@ -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) diff --git a/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md b/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md new file mode 100644 index 0000000..3158953 --- /dev/null +++ b/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md @@ -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; + +/// +/// Audit gap closeout — verifies +/// is reflected in Server.ServerArray after start. Single-server in-process check; the +/// cross-server visibility check lives in OtOpcUa.OpcUaServer.IntegrationTests. +/// +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.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 +/// +/// Peer server URIs published in Server.ServerArray after start, in addition to +/// the local . 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. +/// +public IList PeerApplicationUris { get; set; } = new List(); +``` + +**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 +/// +/// Writes the union of and +/// to the OPC UA standard +/// Server.ServerArray property (NodeId i=2254). Clients in a warm-redundancy +/// deployment discover the partner endpoint by reading this property. +/// +private void PopulateServerArray() +{ + var serverObject = _server?.CurrentInstance?.ServerObject; + if (serverObject is null) return; + + var uris = new List { _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 + + + + false + true + ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + +``` + +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; + +/// +/// Source plan Task 60 — closes the audit gap. Boots two real +/// instances on loopback, each configured with the other's ApplicationUri in +/// . A real OPC UA client connects +/// to Node A, reads Server.ServerArray, and asserts both URIs are visible — the +/// warm-redundancy discovery contract clients depend on. +/// +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 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.None }, + AutoAcceptUntrustedClientCertificates = true, + PeerApplicationUris = peers, + }; + var server = new StandardServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, CancellationToken.None); + return host; + } + + private static async Task 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 diff --git a/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md.tasks.json b/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md.tasks.json new file mode 100644 index 0000000..5b3cb7d --- /dev/null +++ b/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md.tasks.json @@ -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 +} diff --git a/docs/security.md b/docs/security.md index ab2a450..1e53585 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 ``. +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 ``. ### Role grant source diff --git a/docs/v2/Architecture-v2.md b/docs/v2/Architecture-v2.md index 1f35833..4240635 100644 --- a/docs/v2/Architecture-v2.md +++ b/docs/v2/Architecture-v2.md @@ -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` | diff --git a/docs/v2/Cluster.md b/docs/v2/Cluster.md index beed2cb..2840a34 100644 --- a/docs/v2/Cluster.md +++ b/docs/v2/Cluster.md @@ -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`: diff --git a/docs/v2/admin-ui.md b/docs/v2/admin-ui.md index 388f436..bbea657 100644 --- a/docs/v2/admin-ui.md +++ b/docs/v2/admin-ui.md @@ -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 @@ -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 diff --git a/docs/v2/phase-7-status.md b/docs/v2/phase-7-status.md index cb7929d..b7c2cb0 100644 --- a/docs/v2/phase-7-status.md +++ b/docs/v2/phase-7-status.md @@ -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` | diff --git a/docs/v2/redundancy-interop-playbook.md b/docs/v2/redundancy-interop-playbook.md index 3fc6aab..a2a8ed8 100644 --- a/docs/v2/redundancy-interop-playbook.md +++ b/docs/v2/redundancy-interop-playbook.md @@ -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/`. diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index 93ffa51..1a806cf 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -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` + `AddHostedService`. 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. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor index cb20c57..36cbdf1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor @@ -21,6 +21,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor new file mode 100644 index 0000000..4d92e80 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index e5a89e5..a88862c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -1,24 +1,9 @@ @inherits LayoutComponentBase -
- OtOpcUa - - admin console - - - - @context.User.Identity?.Name - - signed in - - - - - signed out - - - -
+@* 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 + (); MainLayout stays statically rendered so the Body RenderFragment + doesn't have to cross an interactive boundary. *@
@* Hamburger toggle: visible only on viewports
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor new file mode 100644 index 0000000..5c697c7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor @@ -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. *@ + + +@if (Expanded) +{ +
+ @ChildContent +
+} + +@code { + /// Section label shown in the eyebrow (e.g. "Scripting"). + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + /// Whether the section is expanded — its child links rendered. + [Parameter] + public bool Expanded { get; set; } + + /// Raised when the eyebrow button is clicked. + [Parameter] + public EventCallback OnToggle { get; set; } + + /// The section's child nav links, rendered only while expanded. + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor new file mode 100644 index 0000000..28f9f77 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor @@ -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. *@ + + + +@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 _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("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; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor index 95964f9..d8755fa 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -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 @@
Live alarm transitions from the cluster's alerts 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).
@if (_rows.Count == 0) {
- 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.
} else diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor index af87ffc..6a4d5d4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor @@ -1,8 +1,11 @@ @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]
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 90f7a30..b223fec 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -20,6 +20,9 @@ public static class EndpointRouteBuilderExtensions public static IEndpointRouteBuilder MapAdminUI(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() .AddInteractiveServerRenderMode(); return app; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor index e952953..87b0159 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css index d1f485d..6bced4c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css @@ -49,6 +49,19 @@ } } +/* 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 +71,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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js new file mode 100644 index 0000000..75c10ad --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js @@ -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"; + } +}; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index ded12f3..06af6df 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -28,6 +28,11 @@ var hasDriver = roles.Contains("driver"); var builder = WebApplication.CreateBuilder(args); +// Razor class library static assets (_content//...) only auto-enable in +// the Development environment. Opt in explicitly so the AdminUI's CSS/JS works +// regardless of ASPNETCORE_ENVIRONMENT. +builder.WebHost.UseStaticWebAssets(); + // 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 +116,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 works + // inside interactive components (NavSidebar's session block). + builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSignalR(); builder.Services.AddOtOpcUaAdminClients(); } @@ -121,6 +129,12 @@ builder.Services.AddOtOpcUaObservability(); var app = builder.Build(); app.UseSerilogRequestLogging(); +// Razor class library static assets (_content//...) 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(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json new file mode 100644 index 0000000..3a61aed --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json @@ -0,0 +1,17 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Opc.Ua": "Information", + "Akka": "Information" + } + } + }, + "Security": { + "Ldap": { + "DevStubMode": false + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json new file mode 100644 index 0000000..bc78f17 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json @@ -0,0 +1,16 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Akka": "Information" + } + } + }, + "Security": { + "Ldap": { + "DevStubMode": false + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json new file mode 100644 index 0000000..3302292 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json @@ -0,0 +1,16 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Opc.Ua": "Debug", + "Akka": "Information" + } + } + }, + "Security": { + "Ldap": { + "DevStubMode": false + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 20978b9..9878d51 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -63,6 +63,15 @@ public sealed class OpcUaApplicationHostOptions /// the Admin UI). Has no effect on None endpoints, which don't exchange certs. /// public bool AutoAcceptUntrustedClientCertificates { get; set; } + + /// + /// Peer server URIs published in Server.ServerArray after start, in addition to + /// the local . 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. + /// + public IList PeerApplicationUris { get; set; } = new List(); } /// @@ -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; } + /// + /// Publishes via the OPC UA + /// standard Server.ServerArray property (NodeId i=2254) so warm-redundancy clients + /// can discover the partner endpoint. + /// + /// The wire-served value of Server.ServerArray comes from + /// (an ) via the + /// SDK's OnReadServerArray callback — writes to + /// ServerObject.ServerArray.Value are NOT what clients read. The SDK auto-populates + /// slot 0 with the local ApplicationUri on ApplicationInstance.Start; 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. + /// + 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(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 { _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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs index 74a5470..094f81d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs @@ -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 { + /// JSON body schema for API-side login callers (kept stable for tests). 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 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( + 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 { - 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) => diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs index 55191eb..9e4c2f7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs @@ -22,6 +22,12 @@ public sealed class LdapAuthService(IOptions options, ILoggerDev-only escape hatch — must be false in production. public bool AllowInsecureLdap { get; set; } + /// + /// Dev-only stub: when true, 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 false in production. + /// + public bool DevStubMode { get; set; } + public string SearchBase { get; set; } = "dc=lmxopcua,dc=local"; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 4dbc1e9..bee04e3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -43,7 +43,11 @@ public static class ServiceCollectionExtensions services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName)); services.AddSingleton(); - services.AddScoped(); + // 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(); services.AddDataProtection() .PersistKeysToDbContext() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs similarity index 98% rename from tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs rename to tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs index eff6d43..2a4a190 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs @@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// Failover scenarios layered on Stop/Restart primitives. /// Covers graceful node loss, rejoin on the same Akka port, and deployment under reduced membership. /// -public sealed class FailoverScenarioTests +public sealed class FailoverDuringDeployTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs new file mode 100644 index 0000000..61b0f49 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs @@ -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; + +/// +/// Source plan Task 60 — closes the audit gap. Boots two real +/// instances on loopback, each configured with the other's ApplicationUri in +/// . A real OPC UA client connects +/// to Node A, reads Server.ServerArray, and asserts both URIs are visible — the +/// warm-redundancy discovery contract clients depend on. +/// +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 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.None }, + AutoAcceptUntrustedClientCertificates = true, + PeerApplicationUris = peers, + }; + var server = new StandardServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, CancellationToken.None); + return host; + } + + private static async Task 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; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj new file mode 100644 index 0000000..3d51e7a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj @@ -0,0 +1,37 @@ + + + + false + true + ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs new file mode 100644 index 0000000..c95d811 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs @@ -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; + +/// +/// Audit gap closeout — verifies +/// is reflected in Server.ServerArray after start. Single-server in-process check; the +/// cross-server visibility check lives in OtOpcUa.OpcUaServer.IntegrationTests. +/// +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.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; + } +}