717 lines
28 KiB
Markdown
717 lines
28 KiB
Markdown
# Akka Hosting Alignment — Gap Closeout Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` to implement this plan task-by-task.
|
|
|
|
**Goal:** Close the four real/cosmetic gaps identified by the audit of `docs/plans/2026-05-26-akka-hosting-alignment-plan.md` so the v2 implementation matches the plan's literal contract (per-role appsettings overlays, explicit dual-endpoint visibility test, plan-prescribed filenames, removal of empty legacy directories).
|
|
|
|
**Architecture:** Additive only. No production-runtime semantics change. One small extension to `OpcUaApplicationHost` so the OPC UA server can advertise peer URIs in `Server.ServerArray` — gated on a new option, defaults to old behavior. Everything else is JSON, test code, file moves, and `rm -rf` of stale bin/obj trees.
|
|
|
|
**Tech Stack:** .NET 10, OPCFoundation .NET Standard SDK (`Opc.Ua.*`), xunit.v3, Shouldly, EF Core 10 (inherited; no schema changes).
|
|
|
|
**Source plan:** `docs/plans/2026-05-26-akka-hosting-alignment-plan.md`. The audit findings closed by this plan map to Tasks 54, 59, 60, and the post-Task-56 cosmetic cleanup. **Read the source plan's "Conventions for every task" block — those rules still apply here.**
|
|
|
|
**Branch:** `v2-gap-closeout` off `master`.
|
|
|
|
---
|
|
|
|
## Conventions for every task
|
|
|
|
- **Branch:** Stay on `v2-gap-closeout`. Never commit to `master` while plan is running.
|
|
- **Build command:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — must be green before commit.
|
|
- **Test command:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build` — relevant new/changed tests must pass.
|
|
- **Commit format:** Conventional Commits matching the source plan — `feat(host):`, `test(opcua):`, `chore(cleanup):`, `refactor(test):`, etc.
|
|
- **Mac compatibility:** All code must build on macOS. The new dual-endpoint test boots two real OPC UA servers on loopback — works on macOS (no Windows-only APIs needed; PKI is created under a per-test temp dir).
|
|
|
|
---
|
|
|
|
## Task 0: Add three role-overlay appsettings files (Task 54 gap)
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** Task 1, Task 5, Task 6
|
|
|
|
**Files:**
|
|
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json`
|
|
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json`
|
|
- Create: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json`
|
|
|
|
**Background:**
|
|
`Program.cs` line 33-35 loads `appsettings.{role-suffix}.json` where the suffix is the roles joined alphabetically with `'-'`. Today the loader passes `optional: true`, so the host boots without these files — but the source plan (Task 54) called them out as required scaffolding so operators have per-role tunable defaults.
|
|
|
|
Suffix matrix:
|
|
| `OTOPCUA_ROLES` env | Loaded file |
|
|
|---|---|
|
|
| `admin` | `appsettings.admin.json` |
|
|
| `driver` | `appsettings.driver.json` |
|
|
| `admin,driver` (any order) | `appsettings.admin-driver.json` (joined alphabetical) |
|
|
|
|
**Step 1: Create `appsettings.admin.json`**
|
|
|
|
Admin-only nodes don't bind drivers; tighten Serilog and disable the LDAP dev stub by default.
|
|
|
|
```json
|
|
{
|
|
"Serilog": {
|
|
"MinimumLevel": {
|
|
"Default": "Information",
|
|
"Override": {
|
|
"Microsoft.AspNetCore": "Warning",
|
|
"Akka": "Information"
|
|
}
|
|
}
|
|
},
|
|
"Security": {
|
|
"Ldap": {
|
|
"DevStubMode": false
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Create `appsettings.driver.json`**
|
|
|
|
Driver-only nodes have no Admin UI; raise OPC UA verbosity slightly so per-node diagnostics flow to logs.
|
|
|
|
```json
|
|
{
|
|
"Serilog": {
|
|
"MinimumLevel": {
|
|
"Default": "Information",
|
|
"Override": {
|
|
"Opc.Ua": "Debug",
|
|
"Akka": "Information"
|
|
}
|
|
}
|
|
},
|
|
"Security": {
|
|
"Ldap": {
|
|
"DevStubMode": false
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Create `appsettings.admin-driver.json`**
|
|
|
|
Combined-role nodes (the docker-dev compose default + the integration test harness) — turn on both surfaces with shared defaults.
|
|
|
|
```json
|
|
{
|
|
"Serilog": {
|
|
"MinimumLevel": {
|
|
"Default": "Information",
|
|
"Override": {
|
|
"Microsoft.AspNetCore": "Warning",
|
|
"Opc.Ua": "Information",
|
|
"Akka": "Information"
|
|
}
|
|
}
|
|
},
|
|
"Security": {
|
|
"Ldap": {
|
|
"DevStubMode": false
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Build green check**
|
|
|
|
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
|
Expected: succeeds. (JSON files do not break the build; this is a smoke check that nothing else regressed.)
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin.json \
|
|
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.driver.json \
|
|
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.admin-driver.json
|
|
git commit -m "feat(host): add per-role appsettings overlays for admin/driver/admin-driver"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Extend `OpcUaApplicationHost` with `PeerApplicationUris` + populate `Server.ServerArray`
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 0, Task 5, Task 6
|
|
|
|
**Files:**
|
|
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs` (add option + post-start population)
|
|
- Test: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs`
|
|
|
|
**Background:**
|
|
The source plan's Task 60 promised a test where "real OPCFoundation client → both endpoints visible in ServerUriArray". That requires production code to populate the peer URIs onto each server's `Server.ServerArray` (NodeId i=2254) property. No such code exists in v2 today — this task adds it as an opt-in option so existing single-node tests keep their current behavior. Task 3 then writes the integration test that drives it across two servers.
|
|
|
|
**Step 1: Write the failing unit test**
|
|
|
|
Create `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs`:
|
|
|
|
```csharp
|
|
using System.IO;
|
|
using System.Net.Sockets;
|
|
using System.Net;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Audit gap closeout — verifies <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>
|
|
/// is reflected in <c>Server.ServerArray</c> after start. Single-server in-process check; the
|
|
/// cross-server visibility check lives in <c>OtOpcUa.OpcUaServer.IntegrationTests</c>.
|
|
/// </summary>
|
|
public sealed class OpcUaApplicationHostServerArrayTests
|
|
{
|
|
[Fact]
|
|
public async Task ServerArray_contains_local_uri_and_configured_peers_after_start()
|
|
{
|
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}");
|
|
try
|
|
{
|
|
var options = new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.UnitTest",
|
|
ApplicationUri = "urn:OtOpcUa.UnitTest.NodeA",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = pkiRoot,
|
|
PeerApplicationUris = new[] { "urn:OtOpcUa.UnitTest.NodeB" },
|
|
};
|
|
|
|
var server = new StandardServer();
|
|
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
|
await host.StartAsync(server, CancellationToken.None);
|
|
|
|
var serverArray = server.CurrentInstance.ServerObject.ServerArray.Value;
|
|
serverArray.ShouldNotBeNull();
|
|
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeA");
|
|
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeB");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static int AllocateFreePort()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run the test — confirm it fails**
|
|
|
|
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"`
|
|
Expected: FAIL with `PeerApplicationUris` not found (compile error) — the option doesn't exist yet.
|
|
|
|
**Step 3: Add the option**
|
|
|
|
Edit `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`. Add to `OpcUaApplicationHostOptions` (after `AutoAcceptUntrustedClientCertificates`, around line 65):
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
|
|
/// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
|
|
/// deployments so OPC UA clients can discover the partner endpoint via the standard
|
|
/// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
|
|
/// is always element 0.
|
|
/// </summary>
|
|
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
|
|
```
|
|
|
|
**Step 4: Populate `Server.ServerArray` after start**
|
|
|
|
Edit `OpcUaApplicationHost.StartAsync` (around line 100-118). After the `_application.Start(server)` call and before the log line, insert:
|
|
|
|
```csharp
|
|
PopulateServerArray();
|
|
```
|
|
|
|
Then add the private method below `AttachUserAuthenticator`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Writes the union of <see cref="OpcUaApplicationHostOptions.ApplicationUri"/> and
|
|
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> to the OPC UA standard
|
|
/// <c>Server.ServerArray</c> property (NodeId i=2254). Clients in a warm-redundancy
|
|
/// deployment discover the partner endpoint by reading this property.
|
|
/// </summary>
|
|
private void PopulateServerArray()
|
|
{
|
|
var serverObject = _server?.CurrentInstance?.ServerObject;
|
|
if (serverObject is null) return;
|
|
|
|
var uris = new List<string> { _options.ApplicationUri };
|
|
foreach (var peer in _options.PeerApplicationUris)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
|
|
uris.Add(peer);
|
|
}
|
|
serverObject.ServerArray.Value = uris.ToArray();
|
|
}
|
|
```
|
|
|
|
**Step 5: Run the test — confirm it passes**
|
|
|
|
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~OpcUaApplicationHostServerArrayTests"`
|
|
Expected: PASS. If `ServerObject.ServerArray.Value` is read-only (some SDK versions guard it), fall back to writing through `ServerArrayNode.Value` via the address-space accessor — but try the direct write first; the SDK exposes it as a settable BaseDataVariableState on `ServerObjectState`.
|
|
|
|
**Step 6: Run full OpcUaServer.Tests suite to confirm no regression**
|
|
|
|
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests`
|
|
Expected: all tests pass — `PopulateServerArray` is additive when `PeerApplicationUris` is empty (default), so existing tests don't change behavior.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs
|
|
git commit -m "feat(opcua): OpcUaApplicationHost publishes peer URIs in Server.ServerArray"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create `OtOpcUa.OpcUaServer.IntegrationTests` project
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Task 5, Task 6 (file moves elsewhere)
|
|
**Depends on:** none (csproj is self-contained)
|
|
|
|
**Files:**
|
|
- Create: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj`
|
|
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx` (add the project)
|
|
|
|
**Background:**
|
|
The source plan's Task 60 named this exact project. Audit found ServiceLevel coverage relocated to other test projects but no `OpcUaServer.IntegrationTests` project exists. Creating the project skeleton in its own task keeps Task 3's commit focused on the test code.
|
|
|
|
**Step 1: Create the csproj**
|
|
|
|
Mirror the conventions in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj`. The integration project needs the `Opc.Ua.Client` package (vs. only `Opc.Ua.Server` in the unit tests) — confirm the version against the existing client CLI's csproj: `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj`.
|
|
|
|
```xml
|
|
<Project Sdk="Microsoft.NET.Sdk">
|
|
|
|
<PropertyGroup>
|
|
<IsPackable>false</IsPackable>
|
|
<IsTestProject>true</IsTestProject>
|
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests</RootNamespace>
|
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
</PropertyGroup>
|
|
|
|
<ItemGroup>
|
|
<PackageReference Include="xunit.v3"/>
|
|
<PackageReference Include="Shouldly"/>
|
|
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
|
<PackageReference Include="xunit.runner.visualstudio">
|
|
<PrivateAssets>all</PrivateAssets>
|
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
</PackageReference>
|
|
</ItemGroup>
|
|
|
|
<ItemGroup>
|
|
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
|
|
</ItemGroup>
|
|
|
|
</Project>
|
|
```
|
|
|
|
If `OPCFoundation.NetStandard.Opc.Ua.Client` isn't in `Directory.Packages.props`, add it there (mirror the existing `OPCFoundation.NetStandard.Opc.Ua.Server` version exactly).
|
|
|
|
**Step 2: Add project to the solution**
|
|
|
|
Run: `dotnet sln ZB.MOM.WW.OtOpcUa.slnx add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj`
|
|
Expected: "Project added to the solution."
|
|
|
|
**Step 3: Build green check**
|
|
|
|
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
|
Expected: builds. (Empty project, so no test discovery yet — `dotnet test` would say "no tests".)
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ \
|
|
ZB.MOM.WW.OtOpcUa.slnx \
|
|
Directory.Packages.props # only if the Opc.Ua.Client version was added there
|
|
git commit -m "test(opcua): scaffold OtOpcUa.OpcUaServer.IntegrationTests project"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `DualEndpointTests` — real OPC UA client reads both URIs from `Server.ServerArray`
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 5, Task 6
|
|
**Depends on:** Task 1 (PeerApplicationUris wiring), Task 2 (IT project exists)
|
|
|
|
**Files:**
|
|
- Create: `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs`
|
|
|
|
**Background:**
|
|
This is the explicit Task 60 deliverable: a real OPC UA client connects to one server and confirms it can discover the partner via `Server.ServerArray`. Single-server unit-side coverage exists in Task 1; this test exercises the wire path with both servers up.
|
|
|
|
**Step 1: Write the test**
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Source plan Task 60 — closes the audit gap. Boots two real <see cref="StandardServer"/>
|
|
/// instances on loopback, each configured with the other's <c>ApplicationUri</c> in
|
|
/// <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/>. A real OPC UA client connects
|
|
/// to Node A, reads <c>Server.ServerArray</c>, and asserts both URIs are visible — the
|
|
/// warm-redundancy discovery contract clients depend on.
|
|
/// </summary>
|
|
public sealed class DualEndpointTests
|
|
{
|
|
private const string NodeAUri = "urn:OtOpcUa.DualEndpoint.NodeA";
|
|
private const string NodeBUri = "urn:OtOpcUa.DualEndpoint.NodeB";
|
|
|
|
[Fact]
|
|
public async Task Client_reads_both_ApplicationUris_from_NodeA_ServerArray()
|
|
{
|
|
var pkiRootA = Path.Combine(Path.GetTempPath(), $"otopcua-pki-a-{Guid.NewGuid():N}");
|
|
var pkiRootB = Path.Combine(Path.GetTempPath(), $"otopcua-pki-b-{Guid.NewGuid():N}");
|
|
var portA = AllocateFreePort();
|
|
var portB = AllocateFreePort();
|
|
|
|
try
|
|
{
|
|
await using var nodeA = await StartNodeAsync(NodeAUri, portA, pkiRootA, peers: new[] { NodeBUri });
|
|
await using var nodeB = await StartNodeAsync(NodeBUri, portB, pkiRootB, peers: new[] { NodeAUri });
|
|
|
|
var serverArray = await ReadServerArrayAsync($"opc.tcp://127.0.0.1:{portA}/OtOpcUa");
|
|
serverArray.ShouldContain(NodeAUri);
|
|
serverArray.ShouldContain(NodeBUri);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(pkiRootA)) Directory.Delete(pkiRootA, recursive: true);
|
|
if (Directory.Exists(pkiRootB)) Directory.Delete(pkiRootB, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static async Task<OpcUaApplicationHost> StartNodeAsync(
|
|
string applicationUri, int port, string pkiRoot, string[] peers)
|
|
{
|
|
var options = new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = applicationUri, // unique per node — SDK uses it for cert CN
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = pkiRoot,
|
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
PeerApplicationUris = peers,
|
|
};
|
|
var server = new StandardServer();
|
|
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
|
await host.StartAsync(server, CancellationToken.None);
|
|
return host;
|
|
}
|
|
|
|
private static async Task<string[]> ReadServerArrayAsync(string endpointUrl)
|
|
{
|
|
var appConfig = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUa.DualEndpointClient",
|
|
ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier(),
|
|
AutoAcceptUntrustedCertificates = true,
|
|
},
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
CertificateValidator = new CertificateValidator(),
|
|
};
|
|
await appConfig.Validate(ApplicationType.Client);
|
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var endpoint = CoreClientUtils.SelectEndpoint(appConfig, endpointUrl, useSecurity: false);
|
|
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
|
|
|
using var session = await Session.Create(
|
|
appConfig, configuredEndpoint, updateBeforeConnect: false,
|
|
sessionName: "DualEndpointTests", sessionTimeout: 60_000,
|
|
identity: new UserIdentity(new AnonymousIdentityToken()),
|
|
preferredLocales: null);
|
|
|
|
var value = session.ReadValue(VariableIds.Server_ServerArray);
|
|
return (string[])value.Value;
|
|
}
|
|
|
|
private static int AllocateFreePort()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run the test**
|
|
|
|
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests`
|
|
Expected: PASS. Wall-time ~3-5 s (two cert-creation cycles + session handshake).
|
|
|
|
If the test hangs on the session handshake on first run, it's the SDK reading the trusted-cert store — bumping `AutoAcceptUntrustedClientCertificates = true` on both server hosts (already set above) should resolve it. If `CoreClientUtils.SelectEndpoint` throws because the SDK version uses a different overload, fall back to constructing the `EndpointDescription` directly with `EndpointUrl = endpointUrl, SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None` and skipping `SelectEndpoint`.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs
|
|
git commit -m "test(opcua): DualEndpointTests — real client reads peer URIs from Server.ServerArray"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Wire `OtOpcUa.OpcUaServer.IntegrationTests` into v2-ci.yml
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** Task 5, Task 6
|
|
**Depends on:** Task 3 (project must exist + have a real test before CI runs it)
|
|
|
|
**Files:**
|
|
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/.github/workflows/v2-ci.yml`
|
|
|
|
**Step 1: Add the project to the `integration` job**
|
|
|
|
Either extend the existing `integration` job to run a second `dotnet test` step, or convert it to a matrix. Prefer a matrix for symmetry with `unit-tests`:
|
|
|
|
Open `.github/workflows/v2-ci.yml`, locate the `integration:` job. Replace it with:
|
|
|
|
```yaml
|
|
integration:
|
|
needs: build
|
|
runs-on: ubuntu-latest
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
project:
|
|
- tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests
|
|
- tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-dotnet@v4
|
|
with:
|
|
dotnet-version: 10.0.x
|
|
- name: dotnet test ${{ matrix.project }}
|
|
run: dotnet test ${{ matrix.project }} --configuration Release --filter "Category!=E2E"
|
|
```
|
|
|
|
**Step 2: Build green check**
|
|
|
|
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests --configuration Release --filter "Category!=E2E"`
|
|
Expected: matches the exact CI command — passes locally so CI will pass too.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add .github/workflows/v2-ci.yml
|
|
git commit -m "ci(v2): include OpcUaServer.IntegrationTests in integration matrix"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Rename `FailoverScenarioTests` → `FailoverDuringDeployTests` (Task 59 cosmetic)
|
|
|
|
**Classification:** trivial
|
|
**Estimated implement time:** ~2 min
|
|
**Parallelizable with:** Task 0, Task 1, Task 2, Task 6 (different files)
|
|
|
|
**Files:**
|
|
- Rename: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs` → `FailoverDuringDeployTests.cs`
|
|
- Modify: class name + namespace-internal references
|
|
|
|
**Step 1: Rename the file and the class**
|
|
|
|
```bash
|
|
git mv tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverScenarioTests.cs \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs
|
|
```
|
|
|
|
Then edit `FailoverDuringDeployTests.cs` and replace the single class declaration `public sealed class FailoverScenarioTests` with `public sealed class FailoverDuringDeployTests`. Use Edit, not sed — the file only declares this class once (`grep -c "FailoverScenario" .` ≤ 2).
|
|
|
|
**Step 2: Sweep for any stale references**
|
|
|
|
Run: `grep -rln "FailoverScenarioTests" .`
|
|
Expected: zero matches after Step 1. If anything appears (e.g., a CI filter, a doc), fix the reference.
|
|
|
|
**Step 3: Build + run test**
|
|
|
|
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~FailoverDuringDeployTests"`
|
|
Expected: same tests pass that previously passed under the old name.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/FailoverDuringDeployTests.cs
|
|
git commit -m "refactor(test): rename FailoverScenarioTests → FailoverDuringDeployTests for plan parity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Delete empty bin/obj-only legacy directories
|
|
|
|
**Classification:** trivial
|
|
**Estimated implement time:** ~2 min
|
|
**Parallelizable with:** Task 0, Task 1, Task 2, Task 5
|
|
|
|
**Files:**
|
|
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Server/`
|
|
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Admin/`
|
|
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/`
|
|
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/`
|
|
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/`
|
|
|
|
**Background:**
|
|
Source plan Task 56 deleted the projects from `ZB.MOM.WW.OtOpcUa.slnx` (confirmed by the audit) but left `bin/`+`obj/` shells on disk. These confuse new contributors and skew directory listings. None of them are referenced anywhere.
|
|
|
|
**Step 1: Sanity-check that each directory is bin/obj-only**
|
|
|
|
```bash
|
|
for dir in \
|
|
src/Server/ZB.MOM.WW.OtOpcUa.Server \
|
|
src/Server/ZB.MOM.WW.OtOpcUa.Admin \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests; do
|
|
echo "--- $dir ---"
|
|
find "$dir" -maxdepth 2 -type f | grep -v "/bin/\|/obj/"
|
|
done
|
|
```
|
|
|
|
Expected: every section is empty (no source files leak out). If any source file shows, STOP and surface it — don't delete blindly.
|
|
|
|
**Step 2: Verify slnx doesn't reference them**
|
|
|
|
Run: `grep -nE 'ZB\.MOM\.WW\.OtOpcUa\.(Server|Admin)(/|\.Tests|\.E2ETests)' ZB.MOM.WW.OtOpcUa.slnx`
|
|
Expected: zero matches.
|
|
|
|
**Step 3: Delete the directories**
|
|
|
|
```bash
|
|
rm -rf src/Server/ZB.MOM.WW.OtOpcUa.Server \
|
|
src/Server/ZB.MOM.WW.OtOpcUa.Admin \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests \
|
|
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests
|
|
```
|
|
|
|
**Step 4: Build green check**
|
|
|
|
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
|
Expected: succeeds (these directories were already out of the solution).
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore(cleanup): remove stale bin/obj shells for deleted v1 Server/Admin projects"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Final build + test green check
|
|
|
|
**Classification:** trivial
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** none (verification, depends on all prior tasks)
|
|
|
|
**Step 1: Restore + build**
|
|
|
|
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
|
Expected: 0 errors, 0 warnings (TreatWarningsAsErrors is on across the solution).
|
|
|
|
**Step 2: Run the full test suite**
|
|
|
|
Run: `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build`
|
|
Expected: all tests green. Specifically confirm:
|
|
- `OpcUaApplicationHostServerArrayTests` (Task 1) — pass
|
|
- `DualEndpointTests` (Task 3) — pass
|
|
- `FailoverDuringDeployTests` (Task 5) — same count of tests pass as before the rename
|
|
|
|
**Step 3: Smoke check the audit assertions**
|
|
|
|
Run:
|
|
```bash
|
|
ls src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.*.json
|
|
find tests/Server -iname "DualEndpointTests.cs" -o -iname "FailoverDuringDeployTests.cs"
|
|
ls -la src/Server/ZB.MOM.WW.OtOpcUa.{Server,Admin} 2>/dev/null
|
|
```
|
|
|
|
Expected:
|
|
- 4 appsettings files: `.json`, `.Development.json`, `.admin.json`, `.admin-driver.json`, `.driver.json`
|
|
- Both renamed/new test files exist
|
|
- The two `ls -la` calls return errors (directories gone)
|
|
|
|
**Step 4: No commit unless cleanup turned up**
|
|
|
|
If anything failed in Steps 1-3, fix it as a follow-up task — do not paper over with a `--no-verify` commit.
|
|
|
|
---
|
|
|
|
## Final verification
|
|
|
|
After Task 7:
|
|
|
|
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green
|
|
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --no-build` — green (incl. 2 new tests)
|
|
3. `git log --oneline master..HEAD` — exactly 6 commits, Conventional-Commits style
|
|
4. Open PR `v2-gap-closeout` → `master` titled "v2: close audit gaps — appsettings overlays, DualEndpointTests, cleanup"
|
|
|
|
---
|
|
|
|
## Task index
|
|
|
|
| # | Title | Class | Time | Parallel with |
|
|
|---|---|---|---|---|
|
|
| 0 | Per-role appsettings overlays | small | 3m | 1, 5, 6 |
|
|
| 1 | OpcUaApplicationHost.PeerApplicationUris + ServerArray | standard | 5m | 0, 5, 6 |
|
|
| 2 | OpcUaServer.IntegrationTests project skeleton | small | 4m | 5, 6 |
|
|
| 3 | DualEndpointTests | standard | 5m | 5, 6 |
|
|
| 4 | CI matrix entry for new IT project | small | 3m | 5, 6 |
|
|
| 5 | Rename FailoverScenarioTests → FailoverDuringDeployTests | trivial | 2m | 0, 1, 2, 6 |
|
|
| 6 | Delete stale bin/obj-only directories | trivial | 2m | 0, 1, 2, 5 |
|
|
| 7 | Final build + test green check | trivial | 3m | none |
|
|
|
|
**Total estimated subagent time:** ~27 min.
|
|
|
|
**Dependency graph (non-parallel pairs):**
|
|
- Task 3 depends on Task 1 (option must exist) and Task 2 (project must exist)
|
|
- Task 4 depends on Task 3 (CI runs the project's tests)
|
|
- Task 7 depends on all prior tasks
|