4d2dfb5b50
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:
1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
point, kept as a test rather than a live-gate note: each cluster stays single-member and
none ever holds another's address as a member.
2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
would be refused at the far side.
3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
process did". The same node then accepts the right key, so the silence is the auth gate
and not a dead endpoint.
All three run fully offline — no Docker fixture — and pass.
Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.
The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
301 lines
12 KiB
C#
301 lines
12 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
|
|
|
/// <summary>
|
|
/// A split-topology node (one carrying a <c>cluster-{ClusterId}</c> role) left on a DPS transport
|
|
/// mode does not error — the transport simply delivers nothing across the mesh boundary, and the
|
|
/// symptom is a deployment that never applies or telemetry that never arrives. These tests make
|
|
/// that misconfiguration a loud host-start failure instead. A node with no cluster-role is exempt
|
|
/// (legacy / single-mesh / test), and the validator is a no-op for it.
|
|
/// </summary>
|
|
[Collection(RoleEnvironmentCollection.Name)]
|
|
public class SplitTopologyTransportValidatorTests
|
|
{
|
|
private static ValidateOptionsResult Validate(
|
|
string? meshMode,
|
|
string? telemetryMode,
|
|
string? telemetryDialMode,
|
|
params string[] roles)
|
|
{
|
|
var pairs = new Dictionary<string, string?>();
|
|
for (var i = 0; i < roles.Length; i++)
|
|
{
|
|
pairs[$"Cluster:Roles:{i}"] = roles[i];
|
|
}
|
|
|
|
if (meshMode is not null) pairs["MeshTransport:Mode"] = meshMode;
|
|
if (telemetryMode is not null) pairs["Telemetry:Mode"] = telemetryMode;
|
|
if (telemetryDialMode is not null) pairs["TelemetryDial:Mode"] = telemetryDialMode;
|
|
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
|
|
|
|
// The validated options instance is never read (all four values come from IConfiguration), so a
|
|
// default MeshTransportOptions is a fine stand-in.
|
|
return new SplitTopologyTransportValidator(configuration)
|
|
.Validate(MeshTransportOptions.SectionName, new MeshTransportOptions());
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_driver_node_on_Dps_mesh_transport_fails()
|
|
{
|
|
// Red-before-green anchor: a split-topology node on DistributedPubSub cannot deliver commands.
|
|
var result = Validate(
|
|
meshMode: MeshTransportOptions.ModeDps,
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: null,
|
|
"driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
|
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
|
result.FailureMessage.ShouldContain("SITE-A");
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_driver_node_on_Grpc_mesh_transport_passes()
|
|
{
|
|
// Grpc is now the ONLY mode that satisfies this rule: it is the only transport that crosses
|
|
// a mesh boundary.
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: null,
|
|
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_admin_node_on_Grpc_mesh_transport_passes()
|
|
{
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: null,
|
|
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
|
"admin", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_node_on_Grpc_mesh_transport_is_matched_case_insensitively()
|
|
{
|
|
Validate(
|
|
meshMode: "grpc",
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
|
"admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_node_on_an_unrecognised_mesh_transport_still_fails()
|
|
{
|
|
// The rule is an explicit allow-list, not "anything that is not Dps" — a typo'd mode is
|
|
// still a mode that delivers nothing across the boundary. The retired 'ClusterClient' value
|
|
// lands here too.
|
|
var result = Validate(
|
|
meshMode: "not-a-transport",
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: null,
|
|
"driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_driver_node_fully_configured_passes()
|
|
{
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: null,
|
|
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_admin_node_fully_configured_passes()
|
|
{
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: null,
|
|
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
|
"admin", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_driver_node_on_Dps_telemetry_fails()
|
|
{
|
|
var result = Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: TelemetryOptions.ModeDps,
|
|
telemetryDialMode: null,
|
|
"driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("Telemetry:Mode");
|
|
result.FailureMessage.ShouldContain(TelemetryOptions.ModeGrpc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_admin_node_on_Dps_telemetry_dial_fails()
|
|
{
|
|
var result = Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: null,
|
|
telemetryDialMode: TelemetryDialOptions.ModeDps,
|
|
"admin", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("TelemetryDial:Mode");
|
|
result.FailureMessage.ShouldContain(TelemetryDialOptions.ModeGrpc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Fused_cluster_role_node_needs_all_three_to_pass()
|
|
{
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
|
"admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Fused_cluster_role_node_missing_telemetry_grpc_fails()
|
|
{
|
|
var result = Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: TelemetryOptions.ModeDps,
|
|
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
|
"admin", "driver", "cluster-MAIN");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("Telemetry:Mode");
|
|
}
|
|
|
|
[Fact]
|
|
public void Fused_cluster_role_node_missing_telemetry_dial_grpc_fails()
|
|
{
|
|
var result = Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: TelemetryDialOptions.ModeDps,
|
|
"admin", "driver", "cluster-MAIN");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("TelemetryDial:Mode");
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_cluster_role_node_on_all_Dps_passes()
|
|
{
|
|
// The exemption that keeps the validator a no-op for every legacy / single-mesh / test node:
|
|
// no cluster-{ClusterId} role means the split-topology rule does not apply at all.
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeDps,
|
|
telemetryMode: TelemetryOptions.ModeDps,
|
|
telemetryDialMode: TelemetryDialOptions.ModeDps,
|
|
"admin", "driver").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Node_with_no_roles_on_all_Dps_passes()
|
|
{
|
|
// Absolute default: nothing declared, nothing to enforce.
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeDps,
|
|
telemetryMode: TelemetryOptions.ModeDps,
|
|
telemetryDialMode: TelemetryDialOptions.ModeDps).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Unset_modes_on_a_cluster_role_node_fail_as_the_Dps_default()
|
|
{
|
|
// A split node that never sets the mode keys inherits the Dps defaults — which the split
|
|
// topology cannot use. Leaving them unset must fail exactly as setting them to Dps would.
|
|
var result = Validate(
|
|
meshMode: null,
|
|
telemetryMode: null,
|
|
telemetryDialMode: null,
|
|
"admin", "driver", "cluster-MAIN");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
|
}
|
|
|
|
[Fact]
|
|
public void Mode_matching_is_case_insensitive()
|
|
{
|
|
Validate(
|
|
meshMode: "grpc",
|
|
telemetryMode: "grpc",
|
|
telemetryDialMode: "grpc",
|
|
"admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_from_OTOPCUA_ROLES_only_on_Dps_fails()
|
|
{
|
|
// Roles-source divergence: Cluster:Roles is empty, so the node's effective Akka roles come from
|
|
// OTOPCUA_ROLES (via RoleParser.Parse) exactly as Program.cs resolves them. A validator that
|
|
// read only Cluster:Roles would see no cluster role and pass silently on Dps — the documented
|
|
// production-incident shape.
|
|
using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A"))
|
|
{
|
|
var pairs = new Dictionary<string, string?> { ["MeshTransport:Mode"] = MeshTransportOptions.ModeDps };
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
|
|
|
|
var result = new SplitTopologyTransportValidator(configuration)
|
|
.Validate(MeshTransportOptions.SectionName, new MeshTransportOptions());
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_from_OTOPCUA_ROLES_is_ignored_when_Cluster_Roles_is_set()
|
|
{
|
|
// When Cluster:Roles is populated it wins outright — the OTOPCUA_ROLES fallback is not consulted,
|
|
// mirroring AkkaClusterOptions.Roles. A non-cluster Cluster:Roles therefore stays exempt even if
|
|
// a stray OTOPCUA_ROLES carries a cluster role.
|
|
using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A"))
|
|
{
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeDps,
|
|
telemetryMode: MeshTransportOptions.ModeDps,
|
|
telemetryDialMode: MeshTransportOptions.ModeDps,
|
|
"admin", "driver").Succeeded.ShouldBeTrue();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Mixed_case_cluster_and_role_names_on_Dps_fail()
|
|
{
|
|
// Case-sensitivity: the Cluster:Roles bind path is verbatim, so mixed-case entries must be
|
|
// normalized here or they slip past the ordinal IsClusterRole / driver checks and pass silently.
|
|
var result = Validate(
|
|
meshMode: MeshTransportOptions.ModeDps,
|
|
telemetryMode: TelemetryOptions.ModeGrpc,
|
|
telemetryDialMode: null,
|
|
"Driver", "Cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
|
}
|
|
|
|
[Fact]
|
|
public void Cluster_role_node_with_neither_admin_nor_driver_only_needs_the_mesh_mode()
|
|
{
|
|
// A cluster-role node that is neither admin nor driver has no telemetry surface to constrain —
|
|
// only the always-on MeshTransport=Grpc rule applies. Telemetry/TelemetryDial left on
|
|
// their Dps defaults must not fail it.
|
|
Validate(
|
|
meshMode: MeshTransportOptions.ModeGrpc,
|
|
telemetryMode: null,
|
|
telemetryDialMode: null,
|
|
"cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
}
|