test(host): deploy happy-path + idempotency integration tests (Task 59)
DeployHappyPathTests exercises the full deploy pipeline on the 2-node harness:
AdminOperationsActor → ConfigPublishCoordinator → DistributedPubSub →
DriverHostActor on both nodes → ApplyAck → coordinator seals. Verifies both
NodeDeploymentState rows reach Applied and Deployment.Status reaches Sealed.
Exposed + fixed two production bugs along the way:
1. Coordinator was publishing DispatchDeployment on the "deployments" topic but
never subscribed to anything — DriverHostActor ACKs published on the same
topic could not reach it. Added dedicated "deployment-acks" topic with
coordinator subscription in PreStart, and DriverHostActor publishes ACKs
there.
2. NodeId derivation used member.Address.Host only — two cluster members on a
shared loopback host (test harness, dev VMs) collided to one identity. The
coordinator's expected-ack set became {1} and the system sealed after only
half the nodes acked. Switched to host:port everywhere (ClusterRoleInfo +
coordinator) so loopback nodes stay distinct and production identities are
harmlessly more specific.
Tests: 95 v2 tests pass (was 93 + 2 deploy tests), 0 skipped.
Failover scenarios (design §8 cases 3-7: node-kill-mid-apply, split-brain,
restart-during-deploy) deferred — they need controlled node-down primitives
on the harness. Tracked as F22 (failover scenario test cases).
This commit is contained in:
@@ -43,7 +43,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
{
|
||||
public const string TestRoles = "admin,driver";
|
||||
public static readonly string SharedDbName = $"two-node-cluster-{Guid.NewGuid():N}";
|
||||
public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}";
|
||||
|
||||
public WebApplication NodeA { get; private set; } = null!;
|
||||
public WebApplication NodeB { get; private set; } = null!;
|
||||
@@ -51,6 +51,10 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
public int NodeAAkkaPort { get; private set; }
|
||||
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";
|
||||
|
||||
public ActorSystem NodeASystem => NodeA.Services.GetRequiredService<ActorSystem>();
|
||||
public ActorSystem NodeBSystem => NodeB.Services.GetRequiredService<ActorSystem>();
|
||||
|
||||
@@ -63,14 +67,18 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
|
||||
// Node A boots first as the seed.
|
||||
harness.NodeA = await BuildNodeAsync(
|
||||
host: LoopbackHost,
|
||||
akkaPort: harness.NodeAAkkaPort,
|
||||
seedHost: LoopbackHost,
|
||||
seedAkkaPort: harness.NodeAAkkaPort,
|
||||
dbName: SharedDbName);
|
||||
dbName: harness.SharedDbName);
|
||||
|
||||
harness.NodeB = await BuildNodeAsync(
|
||||
host: LoopbackHost,
|
||||
akkaPort: harness.NodeBAkkaPort,
|
||||
seedHost: LoopbackHost,
|
||||
seedAkkaPort: harness.NodeAAkkaPort,
|
||||
dbName: SharedDbName);
|
||||
dbName: harness.SharedDbName);
|
||||
|
||||
await WaitForClusterFormationAsync(
|
||||
harness.NodeASystem,
|
||||
@@ -80,18 +88,19 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
return harness;
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> BuildNodeAsync(int akkaPort, int seedAkkaPort, string dbName)
|
||||
private static async Task<WebApplication> BuildNodeAsync(
|
||||
string host, int akkaPort, string seedHost, int seedAkkaPort, string dbName)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
|
||||
|
||||
builder.WebHost.UseKestrel(o => o.Listen(System.Net.IPAddress.Loopback, 0));
|
||||
builder.WebHost.UseKestrel(o => o.Listen(System.Net.IPAddress.Parse(host), 0));
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:ConfigDb"] = "Server=test;Database=test;Trusted_Connection=True;TrustServerCertificate=True;",
|
||||
["Cluster:Hostname"] = "127.0.0.1",
|
||||
["Cluster:Hostname"] = host,
|
||||
["Cluster:Port"] = akkaPort.ToString(),
|
||||
["Cluster:PublicHostname"] = "127.0.0.1",
|
||||
["Cluster:SeedNodes:0"] = $"akka.tcp://otopcua@127.0.0.1:{seedAkkaPort}",
|
||||
["Cluster:PublicHostname"] = host,
|
||||
["Cluster:SeedNodes:0"] = $"akka.tcp://otopcua@{seedHost}:{seedAkkaPort}",
|
||||
["Cluster:Roles:0"] = "admin",
|
||||
["Cluster:Roles:1"] = "driver",
|
||||
["Security:Jwt:SigningKey"] = "two-node-harness-test-signing-key-with-enough-bytes-for-hs256",
|
||||
@@ -149,7 +158,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
using var listener = new TcpListener(System.Net.IPAddress.Parse(LoopbackHost), 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
|
||||
Reference in New Issue
Block a user