feat(config): add ClusterNode.AkkaPort/GrpcPort — central's dial targets
Per-cluster mesh Phase 1 groundwork. Once the meshes split (Phase 2) central can no longer see a site node's appsettings, so the transport ports it must dial have to live in a row central can read. AkkaPort is non-nullable with a 4053 default — every node listens on a remoting port, so 0 is never a truthful value and pre-existing rows must migrate to something real. GrpcPort is nullable with no default: nothing listens on it until Phase 5, and a non-null default would assert a port that does not exist. Both are documented as central's dial targets rather than the node's own binding config; the duplication against Cluster:Port is reconciled in Task 4. The new SchemaCompliance test is red until the migration lands (Task 2). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trips <see cref="ClusterNode.AkkaPort"/> / <see cref="ClusterNode.GrpcPort"/> through
|
||||
/// the real schema (per-cluster mesh Phase 1). These are <b>central's dial targets</b>: Phase 2
|
||||
/// builds its ClusterClient contact points from <c>Host</c> + <c>AkkaPort</c>, and Phase 5 dials
|
||||
/// <c>GrpcPort</c> for the telemetry stream. Central cannot see a site node's appsettings once
|
||||
/// the meshes split, so the value has to live in a row central can read.
|
||||
/// </summary>
|
||||
[Trait("Category", "SchemaCompliance")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class ClusterNodeTransportPortsTests(SchemaComplianceFixture fixture)
|
||||
{
|
||||
/// <summary>Verifies both ports survive a DB round-trip and that AkkaPort defaults to 4053.</summary>
|
||||
[Fact]
|
||||
public async Task Transport_ports_round_trip_and_AkkaPort_defaults_to_4053()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var clusterId = await SeedClusterAsync(ctx);
|
||||
|
||||
// Explicit non-default values on one node — proves the columns are mapped and persisted
|
||||
// rather than silently dropped (an unmapped property round-trips fine in memory).
|
||||
ctx.ClusterNodes.Add(NewNode(clusterId, "explicit", n =>
|
||||
{
|
||||
n.AkkaPort = 4055;
|
||||
n.GrpcPort = 5223;
|
||||
}));
|
||||
// Nothing set on the other — AkkaPort must land on 4053 and GrpcPort must stay null.
|
||||
// GrpcPort is deliberately nullable: nothing listens on it until Phase 5, and a non-null
|
||||
// default would assert a port that does not exist.
|
||||
ctx.ClusterNodes.Add(NewNode(clusterId, "defaulted"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await using var read = NewContext();
|
||||
var explicitNode = await read.ClusterNodes.AsNoTracking()
|
||||
.SingleAsync(n => n.NodeId == $"{clusterId}-explicit:4053");
|
||||
explicitNode.AkkaPort.ShouldBe(4055);
|
||||
explicitNode.GrpcPort.ShouldBe(5223);
|
||||
|
||||
var defaulted = await read.ClusterNodes.AsNoTracking()
|
||||
.SingleAsync(n => n.NodeId == $"{clusterId}-defaulted:4053");
|
||||
defaulted.AkkaPort.ShouldBe(4053);
|
||||
defaulted.GrpcPort.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the columns are queryable server-side — Phase 2's contact-point refresh selects
|
||||
/// them in SQL rather than materialising every node, so a client-side-evaluation regression
|
||||
/// (or an unmapped property) has to fail here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Transport_ports_are_queryable_server_side()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var clusterId = await SeedClusterAsync(ctx);
|
||||
ctx.ClusterNodes.Add(NewNode(clusterId, "a", n => n.GrpcPort = 5223));
|
||||
ctx.ClusterNodes.Add(NewNode(clusterId, "b"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await using var read = NewContext();
|
||||
var contacts = await read.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == clusterId && n.GrpcPort != null)
|
||||
.Select(n => new { n.Host, n.AkkaPort, n.GrpcPort })
|
||||
.ToListAsync();
|
||||
|
||||
contacts.Count.ShouldBe(1);
|
||||
contacts[0].AkkaPort.ShouldBe(4053);
|
||||
contacts[0].GrpcPort.ShouldBe(5223);
|
||||
}
|
||||
|
||||
private static ClusterNode NewNode(string clusterId, string suffix, Action<ClusterNode>? configure = null)
|
||||
{
|
||||
var node = new ClusterNode
|
||||
{
|
||||
NodeId = $"{clusterId}-{suffix}:4053",
|
||||
ClusterId = clusterId,
|
||||
Host = $"{clusterId}-{suffix}",
|
||||
ApplicationUri = $"urn:OtOpcUa:{clusterId}-{suffix}",
|
||||
CreatedBy = nameof(ClusterNodeTransportPortsTests),
|
||||
};
|
||||
configure?.Invoke(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private static async Task<string> SeedClusterAsync(OtOpcUaConfigDbContext ctx)
|
||||
{
|
||||
// Unique per test: the collection shares one database, and ApplicationUri is fleet-wide unique.
|
||||
var clusterId = $"PORTS-{Guid.NewGuid():N}"[..24];
|
||||
ctx.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Name = clusterId,
|
||||
Enterprise = "zb",
|
||||
Site = "ports-test",
|
||||
RedundancyMode = RedundancyMode.Warm,
|
||||
CreatedBy = nameof(ClusterNodeTransportPortsTests),
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
return clusterId;
|
||||
}
|
||||
|
||||
private OtOpcUaConfigDbContext NewContext() =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(fixture.ConnectionString)
|
||||
.Options);
|
||||
}
|
||||
Reference in New Issue
Block a user