diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs index 51161e8f..8d400ef8 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs @@ -21,8 +21,10 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster; /// /// /// The rule is conditional and must stay that way. It fires only when this node's -/// Cluster:Roles contains at least one cluster-scoped role. A legacy / single-mesh / -/// test node has none, so DPS is still legitimate and the validator passes unconditionally. +/// effective Akka roles contain at least one cluster-scoped role — resolved the same +/// way the node resolves them (Cluster:Roles, falling back to OTOPCUA_ROLES when +/// that is empty, normalized case-insensitively). A legacy / single-mesh / test node has none, +/// so DPS is still legitimate and the validator passes unconditionally. /// /// /// Not validated here: ConfigSource:Mode. Phase 4's @@ -55,19 +57,37 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions() ?? Array.Empty(); + // + // Resolve the EFFECTIVE Akka roles exactly as the node does, or this "fail loud" rule fires on + // the wrong role view and passes silently on the two shapes it exists to catch: + // (a) Roles-source divergence. AkkaClusterOptions.Roles binds Cluster:Roles but falls back to + // OTOPCUA_ROLES (via RoleParser.Parse — the same call Program.cs makes) when Cluster:Roles + // is empty. A node configured with only OTOPCUA_ROLES=driver,cluster-SITE-A genuinely + // carries the cluster role in Akka, so it must be validated too. + // (b) Case. The Cluster:Roles bind path is verbatim (no lowercasing), whereas RoleParser.Parse + // lowercases the OTOPCUA_ROLES path. Normalize BOTH here (trim + ToLowerInvariant) so + // 'Cluster-SITE-A' / 'Driver' / 'Admin' cannot slip past the ordinal IsClusterRole / + // driver / admin checks. + var configured = _configuration.GetSection("Cluster:Roles").Get() ?? Array.Empty(); + var effective = configured.Length > 0 + ? configured + : RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES")); - var clusterRole = Array.Find(roles, RoleParser.IsClusterRole); - if (clusterRole is null) + var normalized = Array.ConvertAll(effective, r => r.Trim().ToLowerInvariant()); + + var clusterRoleIndex = Array.FindIndex(normalized, RoleParser.IsClusterRole); + if (clusterRoleIndex < 0) { // No cluster-{ClusterId} role → not the split topology → DPS is legitimate. This is the // exemption that keeps the validator a no-op for every legacy / single-mesh / test node. return ValidateOptionsResult.Success; } - var clusterId = RoleParser.ClusterIdFromRole(clusterRole); - var isDriver = Array.IndexOf(roles, RoleParser.Driver) >= 0; - var isAdmin = Array.IndexOf(roles, RoleParser.Admin) >= 0; + // Classification is normalized (above); the ClusterId in the message is taken from the + // operator's original spelling so it names what they actually wrote. + var clusterId = RoleParser.ClusterIdFromRole(effective[clusterRoleIndex].Trim()); + var isDriver = Array.IndexOf(normalized, RoleParser.Driver) >= 0; + var isAdmin = Array.IndexOf(normalized, RoleParser.Admin) >= 0; var errors = new List(); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs index dc1e54c1..b873db2e 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs @@ -184,4 +184,84 @@ public class SplitTopologyTransportValidatorTests 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 { ["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_ClusterClient() + { + // A cluster-role node that is neither admin nor driver has no telemetry surface to constrain — + // only the always-on MeshTransport=ClusterClient rule applies. Telemetry/TelemetryDial left on + // their Dps defaults must not fail it. + Validate( + meshMode: MeshTransportOptions.ModeClusterClient, + telemetryMode: null, + telemetryDialMode: null, + "cluster-SITE-A").Succeeded.ShouldBeTrue(); + } + + /// Sets an environment variable for the scope's lifetime and restores its prior value on dispose. + private sealed class EnvVarScope : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public EnvVarScope(string name, string? value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); + } }