Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
T
Joseph Doherty 835fc08c3f
v2-ci / build (push) Successful in 3m40s
v2-ci / unit-tests (push) Failing after 1m39s
fix(tests): stop the integration host from ballooning to 30 GB
User-reported: the integration tests leave instances behind and take 18+ GB.
Measured before changing anything — a SINGLE Host.IntegrationTests process peaked
at 30,153 MB RSS across its ~200 tests.

The cause was not a leak and not parallelism. Referencing the Host drags in the
ASP.NET Core framework reference, which turns Server GC on by default: one heap
per core, tuned for throughput and deliberately reluctant to hand memory back.
Correct for a server, wrong for a test host. Workstation GC takes the same suite
from 30,153 MB to 6,717 MB — 78% less — with no change in runtime (45s).

Two hypotheses were measured and rejected on the way, recorded here so nobody
re-runs the experiment: capping xunit maxParallelThreads to 4 cost 29% wall-clock
and saved nothing (30,153 -> 31,115 MB, i.e. noise), and it turns out a single
process was doing all of it. Reverted.

The harness fix is real but was worth only 4% on its own: TwoNodeClusterHarness
disposed each node without stopping it first, and IHost.DisposeAsync only disposes
the service provider — it never invokes IHostedService.StopAsync, which is where
Akka.Hosting terminates the ActorSystem. So every test left two live ActorSystems,
remoting transports and dispatcher pools included, rooted for the process
lifetime. The class doc-comment asserted the opposite ("DisposeAsync, which runs
CoordinatedShutdown"); it was wrong, and StopNodeBAsync inherited the same bug on
a path failover tests depend on.

The payoff is larger than the memory number. Two failures I had classified as
known-flaky baseline noise now pass consistently:
RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed
and ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks.
They were never flaky — they were starved. Full solution: peak summed test-process
RSS 15.2 GB, zero new failures, two fewer.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 10:35:35 -04:00

542 lines
28 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>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. The EF in-memory provider ignores FK constraints, so deploy E2E
/// tests pass without this; against SQL Server each node's <c>NodeDeploymentState</c> INSERT
/// fails without its parent <see cref="ClusterNode"/> row and the deployment never seals.
/// No-op unless <c>OTOPCUA_HARNESS_USE_SQL=1</c> (in-memory needs no seeding). Call once, before
/// <c>StartDeploymentAsync</c>, in tests that don't already seed their own cluster + both nodes.
/// </summary>
/// <param name="clusterId">Cluster id for the seeded rows. Defaults to <c>MAIN</c>.</param>
public async Task SeedDefaultClusterAsync(string clusterId = "MAIN")
{
if (!Mode.UseSqlServer) return;
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 down node B via <see cref="WebApplication.DisposeAsync"/>, which runs
/// CoordinatedShutdown → 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>
public async Task StopNodeBAsync()
{
if (NodeB is null) return;
await NodeB.DisposeAsync();
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));
}
}