diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs index 34e0e3cb..700c8b20 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs @@ -56,6 +56,14 @@ public static class ServiceCollectionExtensions services.AddValidatedOptions( configuration, TelemetryDialOptions.SectionName); + // Per-cluster mesh Phase 6: a node carrying a cluster-{ClusterId} role (the split topology) + // must use the mesh-crossing transports — the DPS dark-switch branches cannot cross a split + // mesh and would deliver nothing silently. Hung on MeshTransportOptions (which already has + // ValidateOnStart from its own registration above) so it fires at boot alongside the sibling + // validators; TryAddEnumerable inside AddValidatedOptions keeps the second validator additive. + services.AddValidatedOptions( + configuration, MeshTransportOptions.SectionName); + services.AddSingleton(); return services; diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs new file mode 100644 index 00000000..51161e8f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +/// +/// Fails the host at startup when a node configured for the split topology (per-cluster +/// mesh, Phase 6) is left on a DistributedPubSub transport mode that cannot cross the mesh +/// boundary. +/// +/// +/// +/// Phase 6 splits the single fleet mesh into one 2-node mesh per application cluster. The +/// Phase 2/3/5 dark switches keep their DPS branches alive, but DPS is mesh-wide — it delivers +/// nothing between a central node and a site node that no longer share a gossip ring. The +/// marker of the split topology is a cluster-{ClusterId} role +/// (); those roles exist only post-Phase-6. A node that +/// carries one must use the mesh-crossing transports, and this validator refuses to +/// boot it otherwise — the alternative is a node that runs, listens, and silently delivers no +/// commands or telemetry, which has no stack trace and no failing node to point at. +/// +/// +/// 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. +/// +/// +/// Not validated here: ConfigSource:Mode. Phase 4's +/// already requires FetchAndCache on a +/// driver-only node, and a fused node legitimately stays Direct — duplicating that here +/// would only risk the two rules drifting apart. +/// +/// +public sealed class SplitTopologyTransportValidator : IValidateOptions +{ + private readonly IConfiguration _configuration; + + /// + /// DI-constructed by AddValidatedOptions (a plain AddSingleton), so a + /// constructor dependency is safe here. — not + /// IClusterRoleInfo — is the source of this node's roles and the three transport modes: + /// IClusterRoleInfo's implementation needs the ActorSystem, which does not exist + /// yet at ValidateOnStart time. Reading the modes from configuration too (rather than + /// from three injected IOptions) mirrors how the sibling validators cross-read + /// Cluster:Roles, and avoids a service-resolution ordering dependency at validation + /// time. + /// + public SplitTopologyTransportValidator(IConfiguration configuration) + { + _configuration = configuration; + } + + /// + public ValidateOptionsResult Validate(string? name, MeshTransportOptions options) + { + // The options instance is intentionally unused: every value this rule depends on is cross-read + // from IConfiguration, the same way ConfigSourceOptionsValidator reads Cluster:Roles. + var roles = _configuration.GetSection("Cluster:Roles").Get() ?? Array.Empty(); + + var clusterRole = Array.Find(roles, RoleParser.IsClusterRole); + if (clusterRole is null) + { + // 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; + + var errors = new List(); + + // Modes are read from configuration directly; an unset key inherits the class default (Dps for + // all three), so a split node that simply never set the key fails exactly as an explicit Dps + // would — which is the point. + var meshMode = _configuration["MeshTransport:Mode"] ?? MeshTransportOptions.ModeDps; + var telemetryMode = _configuration["Telemetry:Mode"] ?? TelemetryOptions.ModeDps; + var telemetryDialMode = _configuration["TelemetryDial:Mode"] ?? TelemetryDialOptions.ModeDps; + + if (!string.Equals(meshMode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase)) + { + errors.Add( + $"Cluster:Roles declares a cluster-{clusterId} role (split topology) but " + + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is " + + $"'{meshMode}'; a split mesh cannot use DistributedPubSub — set " + + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)}=" + + $"{MeshTransportOptions.ModeClusterClient}."); + } + + if (isDriver + && !string.Equals(telemetryMode, TelemetryOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase)) + { + errors.Add( + $"Cluster:Roles declares a cluster-{clusterId} role (split topology) with 'driver' but " + + $"{TelemetryOptions.SectionName}:{nameof(TelemetryOptions.Mode)} is " + + $"'{telemetryMode}'; a split-topology driver node hosts the telemetry gRPC server — " + + $"set {TelemetryOptions.SectionName}:{nameof(TelemetryOptions.Mode)}=" + + $"{TelemetryOptions.ModeGrpc}."); + } + + if (isAdmin + && !string.Equals(telemetryDialMode, TelemetryDialOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase)) + { + errors.Add( + $"Cluster:Roles declares a cluster-{clusterId} role (split topology) with 'admin' but " + + $"{TelemetryDialOptions.SectionName}:{nameof(TelemetryDialOptions.Mode)} is " + + $"'{telemetryDialMode}'; a split-topology central node dials nodes for telemetry — set " + + $"{TelemetryDialOptions.SectionName}:{nameof(TelemetryDialOptions.Mode)}=" + + $"{TelemetryDialOptions.ModeGrpc}."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(string.Join(" ", errors)); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs new file mode 100644 index 00000000..dc1e54c1 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs @@ -0,0 +1,187 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; + +/// +/// A split-topology node (one carrying a cluster-{ClusterId} 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. +/// +public class SplitTopologyTransportValidatorTests +{ + private static ValidateOptionsResult Validate( + string? meshMode, + string? telemetryMode, + string? telemetryDialMode, + params string[] roles) + { + var pairs = new Dictionary(); + 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(); + } +}