Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
T
Joseph Doherty 0b7c53f64f test(harness): widen the deploy-seal timeout to match a real two-node deploy
Comparing full-suite runs on this branch against master in a clean worktree:
both fail 11 tests, 9 identical. Master's two extras are load-flaky unit tests
(AbCip Probe_loops, Galaxy EventPumpBoundedChannel); this branch's two extras
were both Host.IntegrationTests deploy-path tests — the area this change
re-times — so they are attributable here rather than to background flakiness.

Cause is margin, not correctness. These waits used to observe a coordinator
that sealed instantly on an empty expected-ack set; they now observe a real
ApplyAck round-trip from every configured node. 15s was enormous margin against
"instant" and thin against the real thing, so they failed only under full-suite
CPU contention.

Hoisted to TwoNodeClusterHarness.DeploySealTimeout (45s) so the reason is
recorded once rather than as four unexplained numbers.

DriverReconnectE2eTests is deliberately left alone: it seeds both ClusterNode
rows itself and unconditionally, so its expected-ack set is identical before and
after this change. Widening its timeout would be papering over a flake this
change did not cause.

Host.IntegrationTests 195/201; sole failure AbCip_Green_AgainstSim, verified
failing on master.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 09:28:32 -04:00

569 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.Sockets;
using Akka.Actor;
using Akka.Cluster;
using Akka.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.OtOpcUa.AdminUI;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Spins up two in-process <c>OtOpcUa.Host</c>-equivalent <see cref="WebApplication"/> instances
/// that share an <see cref="OtOpcUaConfigDbContext"/> and form a 2-member Akka cluster. Both
/// nodes carry the <c>admin</c> + <c>driver</c> roles, matching design §8's failover-test 2-node
/// profile.
///
/// <para>Default mode uses EF InMemoryDatabase + <see cref="StubLdapAuthService"/>. Optional
/// real-infra modes (env-var driven, see <see cref="HarnessMode.FromEnvironment"/>):</para>
/// <list type="bullet">
/// <item><c>OTOPCUA_HARNESS_USE_SQL=1</c> → swap the in-memory DB for SQL Server on
/// <c>localhost:14331</c> (see <c>docker-compose.yml</c>). Each harness gets a unique
/// database name (<c>OtOpcUa_Harness_{guid}</c>) created via <c>EnsureCreated</c>
/// and dropped via <c>EnsureDeleted</c> on dispose. Override the SQL / LDAP host with
/// <c>OTOPCUA_HARNESS_SQL_HOST</c> / <c>OTOPCUA_HARNESS_LDAP_HOST</c> to point at native
/// fixtures on the shared Docker host (avoids the arm64-Mac emulated-mssql slowdown).</item>
/// <item><c>OTOPCUA_HARNESS_USE_LDAP=1</c> → drop the stub and point <c>LdapAuthService</c>
/// at the GLAuth container on <c>localhost:3894</c> (see Docker/glauth/config.toml).</item>
/// </list>
///
/// <para>Why not <c>WebApplicationFactory&lt;Program&gt;</c>? Program.cs reads <c>OTOPCUA_ROLES</c>
/// from process env (shared across in-process WAF instances) and writes both Serilog file sinks
/// + Akka cluster TCP listener to the host process — neither survives two parallel WAFs cleanly.
/// This harness instead replays the Program.cs DI graph from a clean
/// <see cref="WebApplicationBuilder"/> per node with per-node config overrides.</para>
/// </summary>
public sealed class TwoNodeClusterHarness : IAsyncDisposable
{
public const string TestRoles = "admin,driver";
/// <summary>The deploy-API key both harness nodes are configured with (Security:DeployApiKey).</summary>
public const string HarnessDeployApiKey = "test-deploy-key";
/// <summary>Gets the shared database name for both cluster nodes.</summary>
public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}";
/// <summary>Resolves node A's bound HTTP base address (Kestrel binds an ephemeral port), for
/// driving the REST surface over real HTTP in tests.</summary>
public string NodeABaseAddress => ResolveBaseAddress(NodeA);
private static string ResolveBaseAddress(WebApplication app)
{
var feature = app.Services.GetRequiredService<Microsoft.AspNetCore.Hosting.Server.IServer>()
.Features.Get<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>();
return feature?.Addresses.FirstOrDefault()
?? throw new InvalidOperationException("Node A has no bound HTTP address.");
}
/// <summary>Gets the harness mode configuration from environment variables.</summary>
public HarnessMode Mode { get; } = HarnessMode.FromEnvironment();
private string? _sqlDbName;
private string? _sqlConnString;
/// <summary>Optional opt-in <see cref="IDriverFactory"/> both nodes register so deployed drivers can
/// reach <c>Connected</c>/Healthy in-process. Null (default) leaves the production
/// <see cref="NullDriverFactory"/> from <c>AddOtOpcUaRuntime()</c> in place — unchanged behaviour for
/// existing tests.</summary>
private IDriverFactory? _driverFactory;
/// <summary>Gets the first web application node.</summary>
public WebApplication NodeA { get; private set; } = null!;
/// <summary>Gets the second web application node.</summary>
public WebApplication NodeB { get; private set; } = null!;
/// <summary>Gets the Akka port allocated for node A.</summary>
public int NodeAAkkaPort { get; private set; }
/// <summary>Gets the Akka port allocated for node B.</summary>
public int NodeBAkkaPort { get; private set; }
// Both nodes bind to 127.0.0.1 — ClusterRoleInfo + ConfigPublishCoordinator encode
// host:port into NodeId so the cluster membership stays distinct on different ports.
public const string LoopbackHost = "127.0.0.1";
/// <summary>
/// How long a deploy-seal assertion waits. Generous on purpose: since per-cluster mesh
/// Phase 1 the coordinator waits for a real ApplyAck from every configured node before
/// sealing, so this measures an end-to-end two-node deploy rather than — as it did when the
/// expected-ack set came up empty — an immediate seal. The old 15s had enormous margin
/// against "instant" and thin margin against the real thing, which showed up as deploy
/// tests failing only under full-suite CPU contention.
/// </summary>
public static readonly TimeSpan DeploySealTimeout = TimeSpan.FromSeconds(45);
/// <summary>Gets the Akka ActorSystem for node A.</summary>
public ActorSystem NodeASystem => NodeA.Services.GetRequiredService<ActorSystem>();
/// <summary>Gets the Akka ActorSystem for node B.</summary>
public ActorSystem NodeBSystem => NodeB.Services.GetRequiredService<ActorSystem>();
/// <summary>
/// The <c>host:port</c> identity each node's <c>DriverHostActor</c> / <c>NodeDeploymentState</c>
/// row uses, derived the same way <c>ClusterRoleInfo</c> derives <c>LocalNode</c> from
/// <c>Cluster:PublicHostname</c> + <c>Cluster:Port</c>. Seed a <c>ClusterNode.NodeId</c> with this
/// value to bind a node to a logical ClusterId for multi-cluster scoping tests.
/// </summary>
public string NodeANodeId => $"{LoopbackHost}:{NodeAAkkaPort}";
/// <inheritdoc cref="NodeANodeId"/>
public string NodeBNodeId => $"{LoopbackHost}:{NodeBAkkaPort}";
/// <summary>Opens a new <see cref="OtOpcUaConfigDbContext"/> over the shared ConfigDb (the same
/// store both nodes read) so a test can seed clusters/nodes/drivers before triggering a deploy.</summary>
/// <returns>A new DbContext the caller is responsible for disposing.</returns>
public Task<OtOpcUaConfigDbContext> CreateConfigDbContextAsync()
=> NodeA.Services.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContextAsync();
/// <summary>
/// Seeds a default <see cref="ServerCluster"/> plus a <see cref="ClusterNode"/> row for BOTH
/// harness nodes (<see cref="NodeANodeId"/> / <see cref="NodeBNodeId"/>) so the real-SQL FK
/// constraint <c>FK_NodeDeploymentState_ClusterNode_NodeId</c> is satisfied when a deployment
/// records per-node state. Against SQL Server each node's <c>NodeDeploymentState</c> INSERT
/// fails without its parent <see cref="ClusterNode"/> row and the deployment never seals.
/// Call once, before <c>StartDeploymentAsync</c>, in tests that don't already seed their own
/// cluster + both nodes.
/// </summary>
/// <remarks>
/// <b>This used to no-op in in-memory mode</b> ("the in-memory provider ignores FK
/// constraints, so deploy E2E tests pass without it"). That stopped being true in per-cluster
/// mesh Phase 1: <c>ConfigPublishCoordinator</c> now derives its expected-ack set from these
/// rows rather than from cluster membership, so with none seeded it seals <i>immediately</i>
/// with an empty set. "Wait for Sealed" then no longer implies "every node has applied", and
/// since <c>DriverHostActor.UpsertNodeDeploymentState</c> writes each node's row on its own
/// schedule, any test counting those rows after a seal became a race — reproducibly green
/// alone and red under suite load. Seeding in both modes restores the invariant the tests
/// were written against, and is closer to production either way: a real fleet always has
/// these rows, because the FK requires them.
/// </remarks>
/// <param name="clusterId">Cluster id for the seeded rows. Defaults to <c>MAIN</c>.</param>
public async Task SeedDefaultClusterAsync(string clusterId = "MAIN")
{
await using var db = await CreateConfigDbContextAsync();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = clusterId,
Name = "Harness Default Cluster",
Enterprise = "zb",
Site = "central",
// 2-node cluster → Warm/Hot per CK_ServerCluster_RedundancyMode_NodeCount
// (NodeCount=1↔None, NodeCount=2↔Warm|Hot). The harness runs two nodes.
NodeCount = 2,
RedundancyMode = RedundancyMode.Warm,
CreatedBy = "harness",
});
db.ClusterNodes.AddRange(
new ClusterNode
{
NodeId = NodeANodeId,
ClusterId = clusterId,
Host = LoopbackHost,
ApplicationUri = "urn:zb:harness:node-a",
CreatedBy = "harness",
},
new ClusterNode
{
NodeId = NodeBNodeId,
ClusterId = clusterId,
Host = LoopbackHost,
ApplicationUri = "urn:zb:harness:node-b",
CreatedBy = "harness",
});
await db.SaveChangesAsync();
}
/// <summary>Boots both nodes and waits up to <paramref name="formationTimeout"/> for cluster convergence.</summary>
/// <param name="formationTimeout">Maximum time to wait for cluster formation; defaults to 20 seconds if not provided.</param>
/// <param name="driverFactory">Optional opt-in <see cref="IDriverFactory"/> both nodes register so deployed
/// drivers reach <c>Connected</c>/Healthy in-process. Null (default) leaves the production
/// <see cref="NullDriverFactory"/> default in place — unchanged behaviour for existing tests.</param>
public static async Task<TwoNodeClusterHarness> StartAsync(
TimeSpan? formationTimeout = null,
IDriverFactory? driverFactory = null)
{
var harness = new TwoNodeClusterHarness();
harness._driverFactory = driverFactory;
harness.NodeAAkkaPort = AllocateFreePort();
harness.NodeBAkkaPort = AllocateFreePort();
if (harness.Mode.UseSqlServer)
{
harness._sqlDbName = $"OtOpcUa_Harness_{Guid.NewGuid():N}";
// SQL host is overridable so the heavy 2-node E2E tests can target NATIVE-amd64 SQL on the
// shared Docker host instead of the arm64 Mac's emulated mssql:2022 (no arm64 image → ~5-10×
// slower → the deploy/failover E2E tests time out). Set OTOPCUA_HARNESS_SQL_HOST=10.100.0.35,14331
// with the harness sql container brought up there. Defaults to localhost,14331.
var sqlHost = Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_SQL_HOST") ?? "localhost,14331";
harness._sqlConnString = $"Server={sqlHost};Database={harness._sqlDbName};User Id=sa;Password=OtOpcUa!Harness123;TrustServerCertificate=True;";
await EnsureSqlSchemaCreatedAsync(harness._sqlConnString);
}
// Node A boots first as the seed.
harness.NodeA = await BuildNodeAsync(harness, NodeRole.Seed);
harness.NodeB = await BuildNodeAsync(harness, NodeRole.Joiner);
await WaitForClusterFormationAsync(
harness.NodeASystem,
harness.NodeBSystem,
formationTimeout ?? TimeSpan.FromSeconds(20));
return harness;
}
/// <summary>
/// Gracefully shuts node B down, so CoordinatedShutdown runs and the node performs a
/// Cluster.Leave; node A sees the member transition to Removed within a couple of seconds.
/// Use this for failover scenarios; call <see cref="RestartNodeBAsync"/> to bring it back on
/// the same Akka port.
/// </summary>
/// <remarks>
/// This used to call <c>DisposeAsync</c> alone, on the stated belief that disposal "runs
/// CoordinatedShutdown". It does not — see <see cref="ShutDownAsync"/>. A disposed-but-never-
/// stopped node never leaves the cluster gracefully; node A only notices once the failure
/// detector times it out, which is a slower and materially different path from the graceful
/// leave these failover tests mean to exercise.
/// </remarks>
public async Task StopNodeBAsync()
{
if (NodeB is null) return;
await ShutDownAsync(NodeB);
NodeB = null!;
}
/// <summary>
/// Rebuilds node B on the same Akka port + same ConfigDb and waits for the cluster
/// to re-converge to 2 Up members. Use after <see cref="StopNodeBAsync"/> to test rejoin.
/// </summary>
/// <param name="formationTimeout">The maximum time to wait for cluster formation; defaults to 20 seconds.</param>
public async Task RestartNodeBAsync(TimeSpan? formationTimeout = null)
{
NodeB = await BuildNodeAsync(this, NodeRole.Joiner);
await WaitForClusterFormationAsync(
NodeASystem,
NodeBSystem,
formationTimeout ?? TimeSpan.FromSeconds(20));
}
/// <summary>
/// Abruptly crashes node A (the seed / oldest member) by shutting down its remoting transport — the
/// canonical in-process crash simulation. All associations drop and heartbeats/gossip stop instantly
/// with NO graceful <c>Cluster.Leave</c> (the transport is dead, so A cannot gossip anything). Node B's
/// failure detector therefore marks A <b>Unreachable</b> (not Removed), which is the only state the
/// split-brain resolver acts on: with an active resolver (keep-oldest + down-if-alone, active here via
/// both the akka.conf block and Akka.Cluster.Hosting's default downing provider) B downs the alone
/// oldest node and takes over; with downing disabled (explicit NoDowning) B keeps A Unreachable forever
/// and never fails over. This is the crash the graceful <see cref="StopNodeBAsync"/> path (a real
/// <c>Cluster.Leave</c>, which removes a member with no downing decision) can NOT simulate.
/// The ActorSystem itself stays alive with a dead transport until harness teardown disposes it.
/// </summary>
public async Task HardKillNodeAAsync()
{
if (NodeA is null) return;
var provider = (Akka.Remote.IRemoteActorRefProvider)((ExtendedActorSystem)NodeASystem).Provider;
await provider.Transport.Shutdown();
}
/// <summary>
/// Waits until node B's own cluster view has the crashed node A removed (B is the sole Up member) AND
/// B has become the <c>driver</c>-role leader — i.e. the split-brain resolver downed the alone oldest
/// node and B took over. Queries <see cref="NodeBSystem"/> (node A is dead after a hard kill, so its
/// view can't be read). Returns the elapsed time to takeover; throws <see cref="TimeoutException"/> if
/// B never becomes the sole driver-leader within <paramref name="timeout"/> (the failure signature of
/// an INACTIVE resolver — A stays Unreachable and B never fails over).
/// </summary>
/// <param name="timeout">Upper bound; must exceed acceptable-heartbeat-pause (10s) + stable-after (15s).</param>
public async Task<TimeSpan> WaitForNodeBSoleDriverLeaderAsync(TimeSpan timeout)
{
var start = DateTime.UtcNow;
var deadline = start + timeout;
// Require the outcome to hold across several consecutive polls so a transient during A's
// Up -> Down convergence (the members list can briefly flap while gossip settles) can't be
// mistaken for a settled takeover.
const int requiredStableStreak = 4;
var streak = 0;
while (DateTime.UtcNow < deadline)
{
var clusterB = Akka.Cluster.Cluster.Get(NodeBSystem);
var state = clusterB.State;
var upCount = state.Members.Count(m => m.Status == MemberStatus.Up);
var driverLeader = state.RoleLeader("driver");
// SBR proof: the crashed oldest node has been DOWNED (no longer Up, so B is the sole Up
// member) and B has become the driver-role leader. With the resolver INACTIVE (NoDowning) A
// would instead stay Up-but-Unreachable — B would still see 2 Up members and never gain a
// stable role leader. (A lingers as a Down member in the Unreachable set until reaped; that
// reaping is not part of the failover contract, so it is NOT required here.)
if (upCount == 1 && driverLeader == clusterB.SelfAddress)
{
if (++streak >= requiredStableStreak)
return DateTime.UtcNow - start;
}
else
{
streak = 0;
}
await Task.Delay(250);
}
var finalState = Akka.Cluster.Cluster.Get(NodeBSystem).State;
throw new TimeoutException(
$"Node B did not become the sole driver-role leader within {timeout} after node A was hard-killed. " +
$"Up={finalState.Members.Count(m => m.Status == MemberStatus.Up)}, " +
$"Unreachable={finalState.Unreachable.Count}, " +
$"driverLeader={finalState.RoleLeader("driver")}, selfB={Akka.Cluster.Cluster.Get(NodeBSystem).SelfAddress}. " +
"A stuck-Unreachable A with no takeover is the signature of an INACTIVE split-brain resolver.");
}
/// <summary>
/// Waits for node A's cluster view to reach <paramref name="expectedUpMembers"/> members in
/// <see cref="MemberStatus.Up"/>. Used for asserting shrink-after-stop or grow-after-restart.
/// </summary>
/// <param name="expectedUpMembers">The expected number of Up members in the cluster.</param>
/// <param name="timeout">The maximum time to wait for the expected cluster size.</param>
public async Task WaitForClusterSizeAsync(int expectedUpMembers, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var count = Akka.Cluster.Cluster.Get(NodeASystem).State.Members
.Count(m => m.Status == MemberStatus.Up);
if (count == expectedUpMembers) return;
await Task.Delay(200);
}
var actual = Akka.Cluster.Cluster.Get(NodeASystem).State.Members
.Count(m => m.Status == MemberStatus.Up);
throw new TimeoutException(
$"Cluster did not converge to {expectedUpMembers} Up members within {timeout}. Actual={actual}");
}
private enum NodeRole { Seed, Joiner }
private static async Task<WebApplication> BuildNodeAsync(TwoNodeClusterHarness harness, NodeRole role)
{
var akkaPort = role == NodeRole.Seed ? harness.NodeAAkkaPort : harness.NodeBAkkaPort;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
builder.WebHost.UseKestrel(o => o.Listen(System.Net.IPAddress.Parse(LoopbackHost), 0));
var configOverrides = new Dictionary<string, string?>
{
["ConnectionStrings:ConfigDb"] = harness._sqlConnString
?? "Server=test;Database=test;Trusted_Connection=True;TrustServerCertificate=True;",
["Cluster:Hostname"] = LoopbackHost,
["Cluster:Port"] = akkaPort.ToString(),
["Cluster:PublicHostname"] = LoopbackHost,
["Cluster:SeedNodes:0"] = $"akka.tcp://otopcua@{LoopbackHost}:{harness.NodeAAkkaPort}",
["Cluster:Roles:0"] = "admin",
["Cluster:Roles:1"] = "driver",
["Security:Jwt:SigningKey"] = "two-node-harness-test-signing-key-with-enough-bytes-for-hs256",
["Security:Jwt:Issuer"] = "otopcua-test",
["Security:DeployApiKey"] = HarnessDeployApiKey,
["Security:Jwt:Audience"] = "otopcua-test",
};
if (harness.Mode.UseRealLdap)
{
// Bound section is Security:Ldap (see LdapOptions.SectionName); Transport replaces the
// old UseTls bool and AllowInsecure replaces AllowInsecureLdap (Task 1.4).
configOverrides["Security:Ldap:Enabled"] = "true";
// Overridable alongside the SQL host so the GLAuth container can live on the Docker host too.
configOverrides["Security:Ldap:Server"] = Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_LDAP_HOST") ?? "localhost";
configOverrides["Security:Ldap:Port"] = "3894";
configOverrides["Security:Ldap:Transport"] = "None";
configOverrides["Security:Ldap:AllowInsecure"] = "true";
configOverrides["Security:Ldap:SearchBase"] = "dc=zb,dc=local";
// GLAuth's search-capable bind account (see Docker/glauth/config.toml) — matches the
// shared GLAuth the data-plane binds against. Replaced the retired OpenLDAP cn=admin.
configOverrides["Security:Ldap:ServiceAccountDn"] = "cn=serviceaccount,dc=zb,dc=local";
configOverrides["Security:Ldap:ServiceAccountPassword"] = "serviceaccount123";
}
builder.Configuration.AddInMemoryCollection(configOverrides);
// Provider swap: same DbContext type wired to either InMemory or SqlServer at startup.
if (harness.Mode.UseSqlServer)
{
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(harness._sqlConnString));
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(harness._sqlConnString));
}
else
{
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(harness.SharedDbName));
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(harness.SharedDbName));
}
// Production fidelity: Program.cs calls AddOtOpcUaRuntime() (DI registration) AND
// WithOtOpcUaRuntimeActors() (Akka spawn). The harness historically called only the latter,
// so IDriverHealthPublisher fell back to NullDriverHealthPublisher (no health on the
// driver-health DPS topic) and IDriverFactory to NullDriverFactory (deployed drivers never
// reached Connected). Register the opt-in fake factory FIRST so its registration wins over the
// TryAddSingleton<IDriverFactory>(NullDriverFactory) seeded inside AddOtOpcUaRuntime; when no
// factory was passed, the Null default stays — unchanged behaviour for existing tests. Must run
// BEFORE AddAkka (see the AddOtOpcUaRuntime XML doc).
if (harness._driverFactory is not null)
builder.Services.AddSingleton(harness._driverFactory);
builder.Services.AddOtOpcUaRuntime();
builder.Services.AddOtOpcUaCluster(builder.Configuration);
builder.Services.AddAkka("otopcua", (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp);
ab.WithOtOpcUaControlPlaneSingletons();
ab.WithOtOpcUaRuntimeActors();
});
builder.Services.AddOtOpcUaAuth(builder.Configuration);
if (!harness.Mode.UseRealLdap)
builder.Services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
builder.Services.AddAdminUI();
builder.Services.AddSignalR();
builder.Services.AddOtOpcUaAdminClients();
builder.Services.AddOtOpcUaHealth();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapOtOpcUaAuth();
app.MapOtOpcUaHubs();
app.MapOtOpcUaHealth();
app.MapOtOpcUaDeployApi(app.Configuration);
await app.StartAsync();
return app;
}
private static async Task EnsureSqlSchemaCreatedAsync(string connString)
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(connString)
.Options;
await using var db = new OtOpcUaConfigDbContext(opts);
// EnsureCreated bypasses migrations but builds the model in one shot — fine for tests.
// Production deployments use Migrate-To-V2.ps1 to apply EF migrations.
await db.Database.EnsureCreatedAsync();
}
private static async Task DropSqlDatabaseAsync(string connString)
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(connString)
.Options;
await using var db = new OtOpcUaConfigDbContext(opts);
await db.Database.EnsureDeletedAsync();
}
private static async Task WaitForClusterFormationAsync(ActorSystem a, ActorSystem b, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var aMembers = Akka.Cluster.Cluster.Get(a).State.Members
.Where(m => m.Status == MemberStatus.Up).ToArray();
var bMembers = Akka.Cluster.Cluster.Get(b).State.Members
.Where(m => m.Status == MemberStatus.Up).ToArray();
if (aMembers.Length >= 2 && bMembers.Length >= 2) return;
await Task.Delay(200);
}
throw new TimeoutException(
$"Cluster did not form within {timeout}. " +
$"A up={Akka.Cluster.Cluster.Get(a).State.Members.Count(m => m.Status == MemberStatus.Up)}, " +
$"B up={Akka.Cluster.Cluster.Get(b).State.Members.Count(m => m.Status == MemberStatus.Up)}");
}
/// <summary>
/// Stops a node's hosted services, then disposes it.
/// </summary>
/// <remarks>
/// <para>
/// <b><see cref="IHost.StopAsync"/> is not optional, and disposal is not a substitute for
/// it.</b> <c>Host.DisposeAsync</c> only disposes the service provider; it never invokes
/// <see cref="IHostedService.StopAsync"/>. Akka.Hosting terminates the
/// <see cref="ActorSystem"/> from that callback, so a disposed-but-never-stopped node
/// leaves a live ActorSystem — remoting transport, dispatcher thread pools and all —
/// rooted for the lifetime of the test process.
/// </para>
/// <para>
/// Every test here starts two of them, so the cost compounded across the suite: a single
/// test host was measured at <b>~30 GB RSS</b>, which starved timeout-based assertions
/// into false failures that looked exactly like real regressions.
/// </para>
/// </remarks>
/// <param name="node">The node to stop and dispose.</param>
private static async Task ShutDownAsync(WebApplication node)
{
// Best-effort: a node already stopped (or mid-CoordinatedShutdown from a failover test)
// throws here, and a failure to stop must never mask the test's own assertion failure.
try
{
await node.StopAsync(TimeSpan.FromSeconds(10));
}
catch
{
// fall through to disposal
}
await node.DisposeAsync();
}
private static int AllocateFreePort()
{
using var listener = new TcpListener(System.Net.IPAddress.Parse(LoopbackHost), 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Asynchronously disposes both nodes and cleans up the SQL database if used.</summary>
public async ValueTask DisposeAsync()
{
if (NodeB is not null) await ShutDownAsync(NodeB);
if (NodeA is not null) await ShutDownAsync(NodeA);
if (_sqlConnString is not null)
{
try { await DropSqlDatabaseAsync(_sqlConnString); }
catch { /* best-effort cleanup */ }
}
}
/// <summary>Captures the env-var driven harness mode at construction time.</summary>
public sealed record HarnessMode(bool UseSqlServer, bool UseRealLdap)
{
/// <summary>Creates a HarnessMode from environment variable settings.</summary>
/// <returns>A HarnessMode configured from OTOPCUA_HARNESS_USE_SQL and OTOPCUA_HARNESS_USE_LDAP environment variables.</returns>
public static HarnessMode FromEnvironment() => new(
UseSqlServer: Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_USE_SQL") == "1",
UseRealLdap: Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_USE_LDAP") == "1");
}
private sealed class StubLdapAuthService : ILdapAuthService
{
/// <summary>Asynchronously authenticates a user with the stub LDAP service.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate against.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the LDAP authentication result.</returns>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(new LdapAuthResult(
Success: password == "valid-password",
DisplayName: username,
Username: username,
Groups: ["Administrator"],
Roles: ["Administrator"],
Error: null));
}
}