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, EffectiveRoles.Resolve(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, EffectiveRoles.Resolve(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();
|
|
}
|
|
}
|