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" +}