28 KiB
Akka Hosting Alignment — Gap Closeout Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use
superpowers-extended-cc:executing-plansto 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 tomasterwhile 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.
{
"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.
{
"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.
{
"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
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:
using System.IO;
using System.Net.Sockets;
using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Audit gap closeout — verifies <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>
/// is reflected in <c>Server.ServerArray</c> after start. Single-server in-process check; the
/// cross-server visibility check lives in <c>OtOpcUa.OpcUaServer.IntegrationTests</c>.
/// </summary>
public sealed class OpcUaApplicationHostServerArrayTests
{
[Fact]
public async Task ServerArray_contains_local_uri_and_configured_peers_after_start()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}");
try
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.UnitTest",
ApplicationUri = "urn:OtOpcUa.UnitTest.NodeA",
OpcUaPort = AllocateFreePort(),
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
PeerApplicationUris = new[] { "urn:OtOpcUa.UnitTest.NodeB" },
};
var server = new StandardServer();
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, CancellationToken.None);
var serverArray = server.CurrentInstance.ServerObject.ServerArray.Value;
serverArray.ShouldNotBeNull();
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeA");
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeB");
}
finally
{
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
}
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
Step 2: Run the test — confirm it fails
Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"
Expected: FAIL with PeerApplicationUris not found (compile error) — the option doesn't exist yet.
Step 3: Add the option
Edit src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs. Add to OpcUaApplicationHostOptions (after AutoAcceptUntrustedClientCertificates, around line 65):
/// <summary>
/// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
/// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
/// deployments so OPC UA clients can discover the partner endpoint via the standard
/// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
/// is always element 0.
/// </summary>
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
Step 4: Populate Server.ServerArray after start
Edit OpcUaApplicationHost.StartAsync (around line 100-118). After the _application.Start(server) call and before the log line, insert:
PopulateServerArray();
Then add the private method below AttachUserAuthenticator:
/// <summary>
/// Writes the union of <see cref="OpcUaApplicationHostOptions.ApplicationUri"/> and
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> to the OPC UA standard
/// <c>Server.ServerArray</c> property (NodeId i=2254). Clients in a warm-redundancy
/// deployment discover the partner endpoint by reading this property.
/// </summary>
private void PopulateServerArray()
{
var serverObject = _server?.CurrentInstance?.ServerObject;
if (serverObject is null) return;
var uris = new List<string> { _options.ApplicationUri };
foreach (var peer in _options.PeerApplicationUris)
{
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
uris.Add(peer);
}
serverObject.ServerArray.Value = uris.ToArray();
}
Step 5: Run the test — confirm it passes
Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"
Expected: PASS. If ServerObject.ServerArray.Value is read-only (some SDK versions guard it), fall back to writing through ServerArrayNode.Value via the address-space accessor — but try the direct write first; the SDK exposes it as a settable BaseDataVariableState on ServerObjectState.
Step 6: Run full OpcUaServer.Tests suite to confirm no regression
Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests
Expected: all tests pass — PopulateServerArray is additive when PeerApplicationUris is empty (default), so existing tests don't change behavior.
Step 7: Commit
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.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
</ItemGroup>
</Project>
If OPCFoundation.NetStandard.Opc.Ua.Client isn't in Directory.Packages.props, add it there (mirror the existing OPCFoundation.NetStandard.Opc.Ua.Server version exactly).
Step 2: Add project to the solution
Run: dotnet sln ZB.MOM.WW.OtOpcUa.slnx add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
Expected: "Project added to the solution."
Step 3: Build green check
Run: dotnet build ZB.MOM.WW.OtOpcUa.slnx
Expected: builds. (Empty project, so no test discovery yet — dotnet test would say "no tests".)
Step 4: Commit
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
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Source plan Task 60 — closes the audit gap. Boots two real <see cref="StandardServer"/>
/// instances on loopback, each configured with the other's <c>ApplicationUri</c> in
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>. A real OPC UA client connects
/// to Node A, reads <c>Server.ServerArray</c>, and asserts both URIs are visible — the
/// warm-redundancy discovery contract clients depend on.
/// </summary>
public sealed class DualEndpointTests
{
private const string NodeAUri = "urn:OtOpcUa.DualEndpoint.NodeA";
private const string NodeBUri = "urn:OtOpcUa.DualEndpoint.NodeB";
[Fact]
public async Task Client_reads_both_ApplicationUris_from_NodeA_ServerArray()
{
var pkiRootA = Path.Combine(Path.GetTempPath(), $"otopcua-pki-a-{Guid.NewGuid():N}");
var pkiRootB = Path.Combine(Path.GetTempPath(), $"otopcua-pki-b-{Guid.NewGuid():N}");
var portA = AllocateFreePort();
var portB = AllocateFreePort();
try
{
await using var nodeA = await StartNodeAsync(NodeAUri, portA, pkiRootA, peers: new[] { NodeBUri });
await using var nodeB = await StartNodeAsync(NodeBUri, portB, pkiRootB, peers: new[] { NodeAUri });
var serverArray = await ReadServerArrayAsync($"opc.tcp://127.0.0.1:{portA}/OtOpcUa");
serverArray.ShouldContain(NodeAUri);
serverArray.ShouldContain(NodeBUri);
}
finally
{
if (Directory.Exists(pkiRootA)) Directory.Delete(pkiRootA, recursive: true);
if (Directory.Exists(pkiRootB)) Directory.Delete(pkiRootB, recursive: true);
}
}
private static async Task<OpcUaApplicationHost> StartNodeAsync(
string applicationUri, int port, string pkiRoot, string[] peers)
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = applicationUri, // unique per node — SDK uses it for cert CN
ApplicationUri = applicationUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
PeerApplicationUris = peers,
};
var server = new StandardServer();
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, CancellationToken.None);
return host;
}
private static async Task<string[]> ReadServerArrayAsync(string endpointUrl)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.DualEndpointClient",
ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier(),
AutoAcceptUntrustedCertificates = true,
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
CertificateValidator = new CertificateValidator(),
};
await appConfig.Validate(ApplicationType.Client);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = CoreClientUtils.SelectEndpoint(appConfig, endpointUrl, useSecurity: false);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
using var session = await Session.Create(
appConfig, configuredEndpoint, updateBeforeConnect: false,
sessionName: "DualEndpointTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null);
var value = session.ReadValue(VariableIds.Server_ServerArray);
return (string[])value.Value;
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
Step 2: Run the test
Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests
Expected: PASS. Wall-time ~3-5 s (two cert-creation cycles + session handshake).
If the test hangs on the session handshake on first run, it's the SDK reading the trusted-cert store — bumping AutoAcceptUntrustedClientCertificates = true on both server hosts (already set above) should resolve it. If CoreClientUtils.SelectEndpoint throws because the SDK version uses a different overload, fall back to constructing the EndpointDescription directly with EndpointUrl = endpointUrl, SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None and skipping SelectEndpoint.
Step 3: Commit
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:
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
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
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
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
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
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
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) — passDualEndpointTests(Task 3) — passFailoverDuringDeployTests(Task 5) — same count of tests pass as before the rename
Step 3: Smoke check the audit assertions
Run:
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 -lacalls 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:
dotnet build ZB.MOM.WW.OtOpcUa.slnx— greendotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build— green (incl. 2 new tests)git log --oneline master..HEAD— exactly 6 commits, Conventional-Commits style- Open PR
v2-gap-closeout→mastertitled "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