feat(cluster): refuse to boot a split-topology node on a DPS transport mode
Per-cluster mesh Phase 6 (Task 5). A node carrying a cluster-{ClusterId}
role (RoleParser.IsClusterRole) is in the split topology, where the
Phase 2/3/5 DPS dark-switch branches deliver nothing across the mesh
boundary. SplitTopologyTransportValidator fails host start unless such a
node uses the mesh-crossing transports: MeshTransport:Mode=ClusterClient
always; Telemetry:Mode=Grpc if it has the driver role; TelemetryDial:Mode
=Grpc if it has the admin role. It is a no-op for any node with no
cluster-role (legacy / single-mesh / test). Roles + the three modes are
cross-read from IConfiguration, mirroring ConfigSourceOptionsValidator.
Registered via AddValidatedOptions on MeshTransportOptions with
ValidateOnStart, alongside the sibling validators.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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>
|
||||
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.ModeClusterClient);
|
||||
result.FailureMessage.ShouldContain("SITE-A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_driver_node_fully_configured_passes()
|
||||
{
|
||||
Validate(
|
||||
meshMode: MeshTransportOptions.ModeClusterClient,
|
||||
telemetryMode: TelemetryOptions.ModeGrpc,
|
||||
telemetryDialMode: null,
|
||||
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_admin_node_fully_configured_passes()
|
||||
{
|
||||
Validate(
|
||||
meshMode: MeshTransportOptions.ModeClusterClient,
|
||||
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.ModeClusterClient,
|
||||
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.ModeClusterClient,
|
||||
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.ModeClusterClient,
|
||||
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.ModeClusterClient,
|
||||
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.ModeClusterClient,
|
||||
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: "clusterclient",
|
||||
telemetryMode: "grpc",
|
||||
telemetryDialMode: "grpc",
|
||||
"admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user