From 7209bc99e2a1001114ead544a16b3a332f14049b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:15:59 -0400 Subject: [PATCH 01/19] docs(plans): gap-closeout plan + task persistence file --- ...26-akka-hosting-alignment-gaps-closeout.md | 716 ++++++++++++++++++ ...ting-alignment-gaps-closeout.md.tasks.json | 14 + 2 files changed, 730 insertions(+) create mode 100644 docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md create mode 100644 docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md.tasks.json 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..2a6a911 --- /dev/null +++ b/docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md.tasks.json @@ -0,0 +1,14 @@ +{ + "planPath": "docs/plans/2026-05-26-akka-hosting-alignment-gaps-closeout.md", + "tasks": [ + {"id": 1, "subject": "Task 0: Per-role appsettings overlays", "status": "pending"}, + {"id": 2, "subject": "Task 1: OpcUaApplicationHost.PeerApplicationUris + ServerArray population", "status": "pending"}, + {"id": 3, "subject": "Task 2: OpcUaServer.IntegrationTests project skeleton", "status": "pending"}, + {"id": 4, "subject": "Task 3: DualEndpointTests — real OPC UA client reads both URIs from Server.ServerArray", "status": "pending", "blockedBy": ["2", "3"]}, + {"id": 5, "subject": "Task 4: Wire OpcUaServer.IntegrationTests into v2-ci.yml", "status": "pending", "blockedBy": ["4"]}, + {"id": 6, "subject": "Task 5: Rename FailoverScenarioTests → FailoverDuringDeployTests", "status": "pending"}, + {"id": 7, "subject": "Task 6: Delete empty bin/obj-only legacy directories", "status": "pending"}, + {"id": 8, "subject": "Task 7: Final build + test green check", "status": "pending", "blockedBy": ["1", "2", "3", "4", "5", "6", "7"]} + ], + "lastUpdated": "2026-05-26T00:00:00Z" +} From 25ce111981bae44226b7201974f935e1f1a6c99e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:18:13 -0400 Subject: [PATCH 02/19] =?UTF-8?q?refactor(test):=20rename=20FailoverScenar?= =?UTF-8?q?ioTests=20=E2=86=92=20FailoverDuringDeployTests=20for=20plan=20?= =?UTF-8?q?parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{FailoverScenarioTests.cs => FailoverDuringDeployTests.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/{FailoverScenarioTests.cs => FailoverDuringDeployTests.cs} (98%) 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; From 898a47746d1e39ed5dbcb17138b599aed83de13f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:19:10 -0400 Subject: [PATCH 03/19] feat(host): add per-role appsettings overlays for admin/driver/admin-driver --- .../appsettings.admin-driver.json | 17 +++++++++++++++++ .../appsettings.admin.json | 16 ++++++++++++++++ .../appsettings.driver.json | 16 ++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json 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 + } + } +} From 70ffd2849df030773fbefb89ca0f6f1532a8590a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:21:11 -0400 Subject: [PATCH 04/19] feat(opcua): OpcUaApplicationHost publishes peer URIs in Server.ServerArray --- .../OpcUaApplicationHost.cs | 30 ++++++++++ .../OpcUaApplicationHostServerArrayTests.cs | 59 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 20978b9..0509e60 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,26 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable sessionManager.ImpersonateUser += _impersonateHandler; } + /// + /// 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(); + } + private void OnImpersonateUser(Session session, ImpersonateEventArgs args) => HandleImpersonation(_userAuthenticator, args, _logger); 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; + } +} From 83eda9e8260c36ccfc82290a8d94726eef4e3263 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:23:21 -0400 Subject: [PATCH 05/19] test(opcua): scaffold OtOpcUa.OpcUaServer.IntegrationTests project --- ZB.MOM.WW.OtOpcUa.slnx | 1 + ...tOpcUa.OpcUaServer.IntegrationTests.csproj | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj 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/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..707f8ce --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj @@ -0,0 +1,33 @@ + + + + false + true + ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + From dce2528c688d6ab0ca4553e1608df3fe2f423ad8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:29:53 -0400 Subject: [PATCH 06/19] =?UTF-8?q?test(opcua):=20DualEndpointTests=20?= =?UTF-8?q?=E2=80=94=20real=20client=20reads=20peer=20URIs=20from=20Server?= =?UTF-8?q?.ServerArray?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DualEndpointTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs 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..d1652dd --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs @@ -0,0 +1,121 @@ +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; +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) + { + 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 }, + }; + // SDK 1.5.378 deprecates the no-arg ctors of CertificateValidator / DefaultSessionFactory + // and the non-telemetry overloads of SelectEndpointAsync. Inject a no-op telemetry context + // so the integration test (with TreatWarningsAsErrors=true) doesn't trip the CS0618 wall. + var telemetry = DefaultTelemetry.Create(static _ => { }); + await appConfig.ValidateAsync(ApplicationType.Client, CancellationToken.None); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, useSecurity: false, discoverTimeout: 15_000, telemetry, + CancellationToken.None); + var endpointConfiguration = EndpointConfiguration.Create(appConfig); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); + + var factory = new DefaultSessionFactory(telemetry); + using var session = (ClientSession)await factory.CreateAsync( + appConfig, + configuredEndpoint, + updateBeforeConnect: false, + sessionName: "DualEndpointTests", + sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: null, + CancellationToken.None); + + var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, CancellationToken.None); + 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; + } +} From a5412c16a357bb169ad415a1c332d6a2c88f9d73 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:34:01 -0400 Subject: [PATCH 07/19] fix(test): align DualEndpointTests SDK to 1.5.374.126 + sync API --- .../DualEndpointTests.cs | 22 +++++++------------ ...tOpcUa.OpcUaServer.IntegrationTests.csproj | 8 +++++-- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs index d1652dd..61b0f49 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs @@ -3,7 +3,6 @@ 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; @@ -70,6 +69,9 @@ public sealed class DualEndpointTests 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", @@ -82,31 +84,23 @@ public sealed class DualEndpointTests }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; - // SDK 1.5.378 deprecates the no-arg ctors of CertificateValidator / DefaultSessionFactory - // and the non-telemetry overloads of SelectEndpointAsync. Inject a no-op telemetry context - // so the integration test (with TreatWarningsAsErrors=true) doesn't trip the CS0618 wall. - var telemetry = DefaultTelemetry.Create(static _ => { }); - await appConfig.ValidateAsync(ApplicationType.Client, CancellationToken.None); + await appConfig.Validate(ApplicationType.Client); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; - var endpoint = await CoreClientUtils.SelectEndpointAsync( - appConfig, endpointUrl, useSecurity: false, discoverTimeout: 15_000, telemetry, - CancellationToken.None); + var endpoint = CoreClientUtils.SelectEndpoint(appConfig, endpointUrl, useSecurity: false); var endpointConfiguration = EndpointConfiguration.Create(appConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); - var factory = new DefaultSessionFactory(telemetry); - using var session = (ClientSession)await factory.CreateAsync( + using var session = await ClientSession.Create( appConfig, configuredEndpoint, updateBeforeConnect: false, sessionName: "DualEndpointTests", sessionTimeout: 60_000, identity: new UserIdentity(new AnonymousIdentityToken()), - preferredLocales: null, - CancellationToken.None); + preferredLocales: null); - var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, CancellationToken.None); + var value = session.ReadValue(VariableIds.Server_ServerArray); return (string[])value.Value; } 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 index 707f8ce..3d51e7a 100644 --- 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 @@ -11,8 +11,12 @@ - - + + + all From cb936db7d6b8d8a6923175446d526e9215c3db1f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:39:44 -0400 Subject: [PATCH 08/19] fix(opcua): PopulateServerArray writes IServerInternal.ServerUris so clients see peers --- .../OpcUaApplicationHost.cs | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 0509e60..9878d51 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -154,23 +154,57 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable } /// - /// 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. + /// 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 serverObject = _server?.CurrentInstance?.ServerObject; - if (serverObject is null) return; + 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); + } - var uris = new List { _options.ApplicationUri }; foreach (var peer in _options.PeerApplicationUris) { - if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer)) - uris.Add(peer); + 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(); } - serverObject.ServerArray.Value = uris.ToArray(); } private void OnImpersonateUser(Session session, ImpersonateEventArgs args) => From e8c4f18607a7a0eedb4b7bc248674f873d063fc3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:42:44 -0400 Subject: [PATCH 09/19] ci(v2): include OpcUaServer.IntegrationTests in integration matrix --- .github/workflows/v2-ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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" From bdae749b2b7cdba2f176e60e608d9c1d7f9695eb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:48:05 -0400 Subject: [PATCH 10/19] docs(plans): mark gap-closeout tasks complete --- ...ting-alignment-gaps-closeout.md.tasks.json | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 index 2a6a911..5b3cb7d 100644 --- 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 @@ -1,14 +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": "pending"}, - {"id": 2, "subject": "Task 1: OpcUaApplicationHost.PeerApplicationUris + ServerArray population", "status": "pending"}, - {"id": 3, "subject": "Task 2: OpcUaServer.IntegrationTests project skeleton", "status": "pending"}, - {"id": 4, "subject": "Task 3: DualEndpointTests — real OPC UA client reads both URIs from Server.ServerArray", "status": "pending", "blockedBy": ["2", "3"]}, - {"id": 5, "subject": "Task 4: Wire OpcUaServer.IntegrationTests into v2-ci.yml", "status": "pending", "blockedBy": ["4"]}, - {"id": 6, "subject": "Task 5: Rename FailoverScenarioTests → FailoverDuringDeployTests", "status": "pending"}, - {"id": 7, "subject": "Task 6: Delete empty bin/obj-only legacy directories", "status": "pending"}, - {"id": 8, "subject": "Task 7: Final build + test green check", "status": "pending", "blockedBy": ["1", "2", "3", "4", "5", "6", "7"]} + {"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" + "lastUpdated": "2026-05-26T00:00:00Z", + "finalReview": "approved", + "branchHead": "e8c4f18", + "branchCommitCount": 8 } From 89095c15e340cffd4d5f101d346e0bb85840d4ff Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:58:06 -0400 Subject: [PATCH 11/19] =?UTF-8?q?docs(v2):=20update=20for=20gap-closeout?= =?UTF-8?q?=20=E2=80=94=20peer-URI=20discovery,=20role=20overlays,=20relea?= =?UTF-8?q?se=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 2 +- docs/Redundancy.md | 18 +++++++++++++++++- docs/ServiceHosting.md | 10 ++++++++++ docs/v2/Architecture-v2.md | 1 + docs/v2/Cluster.md | 2 ++ docs/v2/phase-7-status.md | 2 +- docs/v2/redundancy-interop-playbook.md | 15 +++++++++------ docs/v2/v2-release-readiness.md | 3 ++- 8 files changed, 43 insertions(+), 10 deletions(-) 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/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/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/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/phase-7-status.md b/docs/v2/phase-7-status.md index cb7929d..ff25919 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). 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. From 59b3d9f295c2e5fdcb80f880f902ecd0e6a79370 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 12:06:59 -0400 Subject: [PATCH 12/19] docs: rewrite stale src/Server/Server|Admin/ paths to v2 project locations --- docs/AddressSpace.md | 4 ++-- docs/AlarmTracking.md | 7 ++++--- docs/IncrementalSync.md | 5 +++-- docs/OpcUaServer.md | 17 +++++++++-------- docs/ReadWriteOperations.md | 7 +++---- docs/ScriptedAlarms.md | 10 ++++++---- docs/VirtualTags.md | 16 ++++++++-------- docs/security.md | 24 ++++++------------------ 8 files changed, 41 insertions(+), 49 deletions(-) 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/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/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/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/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 From a5c6ce279e81286d9adbf2d379fad634648d3fe4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 12:09:47 -0400 Subject: [PATCH 13/19] docs(v2): finish path corrections in phase-7-status, admin-ui, OpcUaClient fixture --- docs/drivers/OpcUaClient-Test-Fixture.md | 13 +++++++------ docs/v2/admin-ui.md | 6 +++--- docs/v2/phase-7-status.md | 6 +++--- 3 files changed, 13 insertions(+), 12 deletions(-) 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/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 ff25919..b7c2cb0 100644 --- a/docs/v2/phase-7-status.md +++ b/docs/v2/phase-7-status.md @@ -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` | From 2915755a7c3608a104a7ac62189dbe60bbe41368 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 13:48:18 -0400 Subject: [PATCH 14/19] fix(host,security): wire static assets, DI lifetimes, form login, dev-stub LDAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six interlocking fixes surfaced while smoke-testing the fused Host in a browser: - Host/Program.cs: UseStaticWebAssets() opts into the RCL static-asset pipeline in any environment (auto-only in Development), MapStaticAssets().AllowAnonymous() exempts CSS/JS from the AddOtOpcUaAuth fallback policy, and AddCascadingAuthenticationState() lets work inside interactive components (NavSidebar's session block). - Security/ServiceCollectionExtensions: ILdapAuthService Scoped → Singleton — consumed by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes. Crash only surfaced in Development (ValidateOnBuild=true). - Security/Endpoints/AuthEndpoints: /auth/login now dispatches on Content-Type — application/json keeps the original 204/401/503 contract for tests, and application/x-www-form-urlencoded (the browser
) gets a redirect dance. DisableAntiforgery on the login endpoint (it's the entry point, no prior session) and AllowAnonymous to override the fallback policy. - Security/Ldap/LdapOptions + LdapAuthService: real DevStubMode property; when true the auth service bypasses the LDAP bind and returns a FleetAdmin role so dev/test can navigate the full Admin UI without GLAuth running. - AdminUI/EndpointRouteBuilderExtensions: doc-comment update about static-asset flow (the actual MapStaticAssets call lives in Host/Program.cs). --- .../EndpointRouteBuilderExtensions.cs | 3 ++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 14 +++++ .../Endpoints/AuthEndpoints.cs | 53 ++++++++++++++++--- .../Ldap/LdapAuthService.cs | 6 +++ .../Ldap/LdapOptions.cs | 7 +++ .../ServiceCollectionExtensions.cs | 6 ++- 6 files changed, 80 insertions(+), 9 deletions(-) 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.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.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() From e4d0d82f7f6062d0981f6364a072738860b9b5a5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 13:48:35 -0400 Subject: [PATCH 15/19] feat(adminui): collapsible nav sidebar with cookie state + LoginLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the ScadaLink CentralUI sidebar pattern into the OtOpcUa AdminUI: - Drop the top app-bar. Brand moves into the side rail's header — same visual rhythm as ScadaLink's NavMenu. - New NavSection.razor: collapsible eyebrow toggle (rail-eyebrow-toggle CSS) with a chevron + label. Mirrors ScadaLink/Components/Layout/NavSection. - New NavSidebar.razor: interactive island carrying the three section groups (Navigation / Scripting / Live) + session block. Marked @rendermode InteractiveServer; MainLayout itself stays static-rendered because layouts can't take a RenderFragment Body across an interactive boundary. - New wwwroot/js/nav-state.js: window.navState.get/.set persists the expanded-section list to the otopcua_nav cookie (one-year lifetime, SameSite=Lax). Same shape as ScadaLink's scadabridge_nav. - New LoginLayout.razor + @layout LoginLayout on Login.razor: the login page now renders without the side rail — clean centred card. - MainLayout.razor: slimmed down to the d-flex shell + hamburger toggle + + @Body. - Login.razor: also drops the trailing "LDAP bind against the configured directory..." footer that the user asked to remove. - site.css: adds .side-rail .brand styles (mirrored from ScadaLink) and the .rail-eyebrow-toggle / .rail-eyebrow-chevron / .rail-section-body styles for the new collapsible UI. Auto-expand on page load: NavSidebar seeds the expanded set from the current URL's first path segment (in OnInitialized so it works even on the very first server render) and from the cookie (in OnAfterRenderAsync once JS interop is available). LocationChanged hooks keep the expanded state in sync as the user navigates between sections. --- .../Components/App.razor | 1 + .../Components/Layout/LoginLayout.razor | 5 + .../Components/Layout/MainLayout.razor | 65 +------ .../Components/Layout/NavSection.razor | 36 ++++ .../Components/Layout/NavSidebar.razor | 160 ++++++++++++++++++ .../Components/Pages/Login.razor | 11 +- .../ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor | 1 + .../wwwroot/css/site.css | 43 +++++ .../wwwroot/js/nav-state.js | 19 +++ 9 files changed, 274 insertions(+), 67 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js 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/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/_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"; + } +}; From a1a7646b33d15f63ba45eaf5a98b87ca6e4258f4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 13:53:09 -0400 Subject: [PATCH 16/19] fix(adminui): refresh stale F9 stub copy on /alerts page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScriptedAlarmActor (Runtime/ScriptedAlarms) shipped a while back — the "Engine wiring (F9 ScriptedAlarmActor) is pending" stub message was misleading. Also drop the matching "(F9)" / "(future)" parentheticals in the intro panel and frame the empty state as a current-window condition, not a missing feature. --- .../Components/Pages/Alerts.razor | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From 961e09430a3b14628b10808a5bc3ddfbb19929a1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 13:59:23 -0400 Subject: [PATCH 17/19] feat(deploy): add site-a + site-b 2-node clusters to docker-dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the docker-dev compose with two additional, fully-isolated Akka clusters representing distinct sites. Each site is a 2-node fused admin+driver cluster (OTOPCUA_ROLES=admin,driver on both nodes), backed by its own ConfigDb database so configuration state stays separate from the main cluster and from the other site. Cluster isolation: the three meshes share the same Akka system name "otopcua" and remoting port 4053 (inside each container's own network namespace), but their seed-node lists are disjoint — main seeds at admin-a, site-a seeds at site-a-1, site-b seeds at site-b-1 — so gossip doesn't cross between them. Layout: Main cluster ConfigDb=OtOpcUa admin-a, admin-b, driver-a, driver-b Site A ConfigDb=OtOpcUa_SiteA site-a-1, site-a-2 (fused admin+driver) Site B ConfigDb=OtOpcUa_SiteB site-b-1, site-b-2 (fused admin+driver) OPC UA endpoints exposed on host ports 4840-4845. Admin UIs reachable through Traefik via Host-header routing: http://localhost → main cluster (PathPrefix default) http://site-a.localhost → site A http://site-b.localhost → site B `*.localhost` auto-resolves on macOS; Linux users add the two hosts to /etc/hosts (or rely on the resolver's RFC 6761 behaviour). --- docker-dev/README.md | 52 +++++++++--- docker-dev/docker-compose.yml | 141 ++++++++++++++++++++++++++++++--- docker-dev/traefik-dynamic.yml | 42 +++++++++- 3 files changed, 209 insertions(+), 26 deletions(-) diff --git a/docker-dev/README.md b/docker-dev/README.md index 5f08ac2..a9725c0 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -1,20 +1,41 @@ # 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. Each cluster has its own ConfigDb database and its own seed-node list, so Akka.Cluster gossip doesn't cross between them 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 (hosts all per-cluster ConfigDb databases) | 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 (ConfigDb: `OtOpcUa`) + +| 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 (ConfigDb: `OtOpcUa_SiteA`) + +| 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 (ConfigDb: `OtOpcUa_SiteB`) + +| 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. Cluster isolation is enforced purely by disjoint seed lists. ## Bring up @@ -22,12 +43,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 +83,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..0a153ed 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -1,18 +1,37 @@ -# 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 sharing the same SQL + LDAP): +# sql SQL Server 2022 (per-cluster ConfigDb databases) +# 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) +# ConfigDb: OtOpcUa +# +# Site A cluster (2-node fused admin+driver — its own ConfigDb + seed): +# site-a-1, site-a-2 OTOPCUA_ROLES=admin,driver, seed = site-a-1 +# ConfigDb: OtOpcUa_SiteA +# +# Site B cluster (2-node fused admin+driver — its own ConfigDb + seed): +# site-b-1, site-b-2 OTOPCUA_ROLES=admin,driver, seed = site-b-1 +# ConfigDb: OtOpcUa_SiteB +# +# 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). +# +# Cluster isolation is enforced by disjoint seed-node lists — Akka.Cluster gossip won't +# cross between the three meshes even though they share the same system name "otopcua". # # 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 @@ -113,6 +132,102 @@ services: ports: - "4841:4840" + # ── Site A cluster (2-node fused admin+driver) ────────────────────────────── + # Its own ConfigDb (OtOpcUa_SiteA) + its own seed (site-a-1) → Akka isolation + # from the main cluster and from site B. Both nodes carry both roles. + + site-a-1: + <<: *otopcua-host + environment: + OTOPCUA_ROLES: "admin,driver" + ASPNETCORE_URLS: "http://+:9000" + ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa_SiteA;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_SiteA;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_SiteB;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_SiteB;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 +243,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/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 From 993e012e552f09e520d10d7872d363705aad41be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 14:02:24 -0400 Subject: [PATCH 18/19] fix(deploy): site clusters share the single OtOpcUa ConfigDb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (961e094) gave each site cluster its own database (OtOpcUa_SiteA / OtOpcUa_SiteB). That fights the architecture — ConfigDb is multi-tenant by design: one schema with a ServerCluster table whose rows scope the rest of the configuration via ClusterId. Per-cluster databases would split the schema and force every singleton/coordinator to point at a different connection string. Correct model: one ConfigDb, three ServerCluster rows (MAIN / SITE-A / SITE-B), each Akka cluster's ClusterNode rows pointing back at the matching ClusterId. Akka mesh isolation is still enforced by the disjoint seed-node lists (unchanged from the previous commit). Compose: all eight host nodes now point at Server=sql,1433;Database=OtOpcUa and the README documents the post-boot ServerCluster + ClusterNode rows operators need to create via /clusters and /hosts before the runtime can resolve its scope. --- docker-dev/README.md | 24 ++++++++++++++++++------ docker-dev/docker-compose.yml | 35 ++++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/docker-dev/README.md b/docker-dev/README.md index a9725c0..e393131 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -1,6 +1,6 @@ # docker-dev -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. Each cluster has its own ConfigDb database and its own seed-node list, so Akka.Cluster gossip doesn't cross between them even though they share the same system name `otopcua`. +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 @@ -8,11 +8,11 @@ Mac-friendly multi-cluster OtOpcUa fleet for manual UI exercise + integration sm | Service | Role | Ports | |---|---|---| -| `sql` | SQL Server 2022 (hosts all per-cluster ConfigDb databases) | 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` | | `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8080` | -### Main cluster — split admin/driver roles (ConfigDb: `OtOpcUa`) +### Main cluster — split admin/driver roles | Service | Role | Ports | |---|---|---| @@ -21,21 +21,33 @@ Mac-friendly multi-cluster OtOpcUa fleet for manual UI exercise + integration sm | `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 (ConfigDb: `OtOpcUa_SiteA`) +### 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 (ConfigDb: `OtOpcUa_SiteB`) +### 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. Cluster isolation is enforced purely by disjoint seed lists. +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. After the stack comes up clean for the first time, sign in to any admin UI and create the three rows (or do it via `dotnet run` against the Configuration project's seed script): + +| Akka mesh | Suggested `ClusterId` | Nodes (`ClusterNode.NodeId`) | +|---|---|---| +| Main | `MAIN` | `admin-a`, `admin-b`, `driver-a`, `driver-b` | +| Site A | `SITE-A` | `site-a-1`, `site-a-2` | +| Site B | `SITE-B` | `site-b-1`, `site-b-2` | + +The `NodeId` for each `ClusterNode` row must match the node's `Cluster__PublicHostname` env value (Compose service name) — that's the lookup the runtime uses to find its own membership. ## Bring up diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 0a153ed..949117b 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -1,7 +1,7 @@ # docker-dev/ — Mac-friendly multi-cluster fleet for v2 development + manual UI exercise. # -# Stack (3 separate Akka clusters sharing the same SQL + LDAP): -# sql SQL Server 2022 (per-cluster ConfigDb databases) +# 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): @@ -9,22 +9,26 @@ # 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) -# ConfigDb: OtOpcUa # -# Site A cluster (2-node fused admin+driver — its own ConfigDb + seed): +# Site A cluster (2-node fused admin+driver): # site-a-1, site-a-2 OTOPCUA_ROLES=admin,driver, seed = site-a-1 -# ConfigDb: OtOpcUa_SiteA # -# Site B cluster (2-node fused admin+driver — its own ConfigDb + seed): +# Site B cluster (2-node fused admin+driver): # site-b-1, site-b-2 OTOPCUA_ROLES=admin,driver, seed = site-b-1 -# ConfigDb: OtOpcUa_SiteB # # 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). # -# Cluster isolation is enforced by disjoint seed-node lists — Akka.Cluster gossip won't -# cross between the three meshes even though they share the same system name "otopcua". +# 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 @@ -133,15 +137,16 @@ services: - "4841:4840" # ── Site A cluster (2-node fused admin+driver) ────────────────────────────── - # Its own ConfigDb (OtOpcUa_SiteA) + its own seed (site-a-1) → Akka isolation - # from the main cluster and from site B. Both nodes carry both roles. + # 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_SiteA;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + 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" @@ -165,7 +170,7 @@ services: environment: OTOPCUA_ROLES: "admin,driver" ASPNETCORE_URLS: "http://+:9000" - ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa_SiteA;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + 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" @@ -188,7 +193,7 @@ services: environment: OTOPCUA_ROLES: "admin,driver" ASPNETCORE_URLS: "http://+:9000" - ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa_SiteB;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + 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" @@ -212,7 +217,7 @@ services: environment: OTOPCUA_ROLES: "admin,driver" ASPNETCORE_URLS: "http://+:9000" - ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa_SiteB;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + 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" From f02071c9a26d6386788b90a22fd3d422d3befa0e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 14:06:47 -0400 Subject: [PATCH 19/19] feat(deploy): bake the ServerCluster/ClusterNode seed into docker-compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a one-shot cluster-seed service to docker-dev/docker-compose.yml that pre-populates the three Akka clusters' scope rows in the shared OtOpcUa ConfigDb so operators don't have to click through /clusters + /hosts on every fresh bring-up. Seed contents: ServerCluster MAIN (Warm/2), SITE-A (Warm/2), SITE-B (Warm/2) ClusterNode driver-a + driver-b → MAIN site-a-1 + site-a-2 → SITE-A site-b-1 + site-b-2 → SITE-B NodeCount + RedundancyMode honour the CK_ServerCluster check constraint. ApplicationUri follows the urn:OtOpcUa: convention; uniqueness across the fleet satisfies UX_ClusterNode_ApplicationUri. Mechanism: - docker-dev/seed/seed-clusters.sql — idempotent INSERTs (IF NOT EXISTS guards on every row). - docker-dev/seed/entrypoint.sh — bash wrapper that waits for SQL to accept connections, then polls until dbo.ServerCluster exists (the host containers' EF auto-migration creates it on first boot), then applies the SQL script. - cluster-seed service uses mcr.microsoft.com/mssql-tools as the base image (bash + sqlcmd available), restart: "no" so it runs once. Re-running `docker compose up` is safe: the seed exits cleanly on the second run because every INSERT is guarded. Manual re-seed: `docker compose run --rm cluster-seed`. --- docker-dev/README.md | 18 +++-- docker-dev/docker-compose.yml | 14 ++++ docker-dev/seed/entrypoint.sh | 35 ++++++++++ docker-dev/seed/seed-clusters.sql | 106 ++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) create mode 100755 docker-dev/seed/entrypoint.sh create mode 100644 docker-dev/seed/seed-clusters.sql diff --git a/docker-dev/README.md b/docker-dev/README.md index e393131..4bd15fd 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -39,15 +39,25 @@ All containers bind Akka remoting to port `4053` inside their own network namesp ## 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. After the stack comes up clean for the first time, sign in to any admin UI and create the three rows (or do it via `dotnet run` against the Configuration project's seed script): +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. -| Akka mesh | Suggested `ClusterId` | Nodes (`ClusterNode.NodeId`) | +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` | `admin-a`, `admin-b`, `driver-a`, `driver-b` | +| 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` | -The `NodeId` for each `ClusterNode` row must match the node's `Cluster__PublicHostname` env value (Compose service name) — that's the lookup the runtime uses to find its own membership. +`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 diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 949117b..69ba80e 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -57,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: 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;