diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs index d3043e0e..d3e41863 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptions.cs @@ -6,11 +6,18 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster; /// /// /// -/// is a deliberate dark switch, not a permanent knob: both -/// transports live in the tree for one phase so the docker-dev rig can run the same -/// deployment over each and compare, and so a bad Phase 2 rolls back with a config change -/// rather than a revert. Phase 6 deletes the DistributedPubSub branch along with the single -/// mesh that made it viable. +/// is a deliberate dark switch: the transports live in the tree +/// side by side so the docker-dev rig can run the same deployment over each and compare, and +/// so a bad rollout rolls back with a config change rather than a revert. +/// +/// +/// The DistributedPubSub branch is retained, not scheduled for deletion. An earlier +/// comment here promised Phase 6 would delete it along with the single mesh; that was +/// deliberately reversed — see docs/Configuration.md § "The compiled transport-mode +/// defaults were deliberately NOT flipped". DPS remains the correct and default transport for +/// a single-mesh topology, which every integration test and every profile that leaves this +/// key unset still runs. What is being replaced is , by +/// . /// /// /// Discovery is deliberately asymmetric, matching the sister project. Central @@ -32,9 +39,19 @@ public sealed class MeshTransportOptions public const string ModeClusterClient = "ClusterClient"; /// - /// (default) or . Any other value fails - /// the host at startup rather than silently falling back — a transport that is neither of - /// these delivers nothing, and the symptom would be commands that vanish. + /// Commands cross the mesh boundary as unary gRPC calls on the mesh_transport.v1 + /// contract — central dials each node's NodeCommandService, a node posts its + /// deployment ack to central's CentralCommandService. The replacement for + /// ; unlike it, the command plane is authenticated + /// (). + /// + public const string ModeGrpc = "Grpc"; + + /// + /// (default), or + /// . Any other value fails the host at startup rather than silently + /// falling back — a transport that is none of these delivers nothing, and the symptom would + /// be commands that vanish. /// public string Mode { get; set; } = ModeDps; @@ -58,4 +75,88 @@ public sealed class MeshTransportOptions /// process — migration, direct SQL, a second admin node — is picked up without a restart. /// public int ContactRefreshSeconds { get; set; } = 60; + + /// + /// MeshTransport:CentralGrpcEndpoints — node-side only, and only under + /// : the gRPC base addresses of the central pair's + /// CentralCommandService, e.g. http://central-1:4055. Full URLs + /// (scheme://host:port), not bare host:port — http:// is h2c. + /// + /// + /// Tried in order, stopping at the first success, which is what preserves the + /// receptionist's one-ack-per-node-per-dispatch semantic: either central node routes the ack + /// to the same ConfigPublishCoordinator singleton proxy, so which one answers does not + /// matter, but two answering would double-count. The gRPC counterpart of + /// , and it replaces it — a node running + /// never reads the Akka contact points. + /// + public string[] CentralGrpcEndpoints { get; set; } = []; + + /// + /// MeshTransport:GrpcPsk — this node's pre-shared key for the command plane, one per + /// application Cluster. Under it is both the bearer this node + /// presents when posting an ack to central and the bearer it requires on inbound + /// NodeCommandService calls. + /// + /// + /// + /// Production form is a secret reference — ${secret:OT-MESH-PSK-<clusterId>}, + /// e.g. ${secret:OT-MESH-PSK-SITE-A} — resolved pre-host by the existing + /// SecretReferenceExpander, so it works on a driver-only node with no ConfigDb. + /// Supply a literal only on a rig, via the environment + /// (MeshTransport__GrpcPsk); never commit one. + /// + /// + /// Per-Cluster, not fleet-wide, on purpose. The three existing shared-bearer gRPC + /// channels are read/telemetry planes; this one carries RestartDriver, + /// AlarmCommand and deployment dispatch, so a single fleet key would let one + /// compromised site node command every other site and forge acks fleet-wide. + /// + /// + /// Nullable because unset must be distinguishable from blank at the boundary that + /// fails closed: the serve-side interceptor rejects every call when no key is + /// configured rather than defaulting to an empty comparison that any empty bearer would + /// satisfy. + /// + /// + public string? GrpcPsk { get; set; } + + /// + /// MeshTransport:DispatchTimeoutSeconds — central-side deadline, in seconds, for one + /// NodeCommandService.Execute call to one node. Must be greater than 0 under + /// . + /// + /// + /// Bounded per node because a fan-out dispatch dials every enabled node in the cluster: an + /// unbounded call to one unreachable node would stall the commands queued behind it. Expiry + /// is logged and dropped — the transport is fire-and-forget and layers no retry, so the + /// deploy path's 2-minute apply deadline stays the only recovery mechanism. + /// + public int DispatchTimeoutSeconds { get; set; } = 10; + + /// + /// MeshTransport:AckTimeoutSeconds — node-side deadline, in seconds, for one + /// CentralCommandService.ReportApply call to one central endpoint. Must be greater + /// than 0 under . + /// + /// + /// Applied per endpoint, not per ack: the node walks in + /// order, so a dead first endpoint costs this deadline before the second is tried. + /// + public int AckTimeoutSeconds { get; set; } = 10; + + /// + /// MeshTransport:ClusterPsks:{clusterId} — central-side override map of per-cluster + /// PSKs, consulted before the Secrets store when central builds a channel to (or + /// verifies an ack from) a given Cluster. Keyed case-insensitively. + /// + /// + /// Production leaves this empty and the runtime provider resolves + /// OT-MESH-PSK-{clusterId} from the Secrets store instead; the map exists so the + /// docker-dev rig and integration tests can supply literals through the environment + /// (MeshTransport__ClusterPsks__SITE-A) without standing up a KEK. Central needs a map + /// rather than a single key because its cluster set is DB-dynamic — one key per site pair it + /// talks to — whereas a node holds exactly its own (). + /// + public Dictionary ClusterPsks { get; set; } = new(StringComparer.OrdinalIgnoreCase); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs index afcd8779..ad47fc88 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; namespace ZB.MOM.WW.OtOpcUa.Cluster; @@ -15,6 +16,22 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster; /// public sealed class MeshTransportOptionsValidator : 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: IClusterRoleInfo's + /// implementation needs the ActorSystem, which does not exist yet at + /// ValidateOnStart time. Same shape as and + /// . + /// + /// The application configuration, read for Cluster:Roles. + public MeshTransportOptionsValidator(IConfiguration configuration) + { + _configuration = configuration; + } + /// public ValidateOptionsResult Validate(string? name, MeshTransportOptions options) { @@ -25,13 +42,14 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions()); + var isDriver = Array.IndexOf(roles, RoleParser.Driver) >= 0; + + if (isGrpc && isDriver) + { + if (string.IsNullOrWhiteSpace(options.GrpcPsk)) + { + errors.Add( + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.GrpcPsk)} is " + + $"empty. Under {MeshTransportOptions.ModeGrpc} this per-cluster pre-shared key is " + + "the whole authentication boundary of the command plane in both directions — " + + "without it this node's inbound command service fails closed and every ack it " + + "posts to central is rejected. Supply it via the environment " + + $"({MeshTransportOptions.SectionName}__{nameof(MeshTransportOptions.GrpcPsk)}), in " + + "production as ${secret:OT-MESH-PSK-}."); + } + + if (options.CentralGrpcEndpoints.Length == 0) + { + errors.Add( + $"{MeshTransportOptions.SectionName}:" + + $"{nameof(MeshTransportOptions.CentralGrpcEndpoints)} is empty. Under " + + $"{MeshTransportOptions.ModeGrpc} a driver node has nowhere to post its " + + "deployment ack, and every deployment would time out naming a node that is " + + "running perfectly. Supply them via the environment " + + $"({MeshTransportOptions.SectionName}__" + + $"{nameof(MeshTransportOptions.CentralGrpcEndpoints)}__0)."); + } + + foreach (var endpoint in options.CentralGrpcEndpoints) + { + if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + errors.Add( + $"Central gRPC endpoint '{endpoint}' must start with 'http://' (h2c) or " + + "'https://' — it is a gRPC base address (scheme://host:port), not a bare " + + "host:port and not an Akka remoting address."); + } + } + + // Both deadlines are per-call and there is no retry behind them, so a non-positive value + // is not "wait forever" — it is a call that expires before it is made, i.e. a command + // plane that silently delivers nothing. + if (options.DispatchTimeoutSeconds <= 0) + { + errors.Add( + $"{MeshTransportOptions.SectionName}:" + + $"{nameof(MeshTransportOptions.DispatchTimeoutSeconds)} must be greater than 0 " + + $"(was {options.DispatchTimeoutSeconds})."); + } + + if (options.AckTimeoutSeconds <= 0) + { + errors.Add( + $"{MeshTransportOptions.SectionName}:" + + $"{nameof(MeshTransportOptions.AckTimeoutSeconds)} must be greater than 0 " + + $"(was {options.AckTimeoutSeconds})."); + } + } + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(string.Join(" ", errors)); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs index e9a80a95..5a5bb597 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs @@ -98,14 +98,22 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions /// /// The fallback keeps ~12 existing tests (and any node wired before per-cluster mesh Phase 2) - /// on exactly the pre-Phase-2 behaviour. It is not a permanent seam: Phase 6 deletes it along - /// with the DistributedPubSub branch and the single mesh. + /// on exactly the pre-Phase-2 behaviour. The DistributedPubSub branch behind it is + /// retained, not scheduled for deletion — an earlier note here promised Phase 6 would + /// remove it, which was deliberately reversed (docs/Configuration.md § "The compiled + /// transport-mode defaults were deliberately NOT flipped"): DPS is still the correct and + /// default transport for a single-mesh topology. What is being replaced is the ClusterClient + /// branch, by gRPC. /// /// The legacy DistributedPubSub topic. /// The command. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs index 86787656..f71c4c74 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs @@ -68,8 +68,11 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers /// /// /// Null in tests and on any node wired before Phase 2, where the fallback reproduces the - /// pre-Phase-2 behaviour exactly. The fallback is not a permanent seam — Phase 6 deletes it - /// along with the DistributedPubSub branch and the single mesh. + /// pre-Phase-2 behaviour exactly. The DistributedPubSub branch behind it is retained, + /// not scheduled for deletion — an earlier note here promised Phase 6 would remove it, which + /// was deliberately reversed (docs/Configuration.md § "The compiled transport-mode + /// defaults were deliberately NOT flipped"): DPS is still the correct and default transport + /// for a single-mesh topology. What is being replaced is the ClusterClient branch, by gRPC. /// private readonly IActorRef? _meshRouter; private readonly ILoggingAdapter _log = Context.GetLogger(); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs index af627a9b..1fb4ce46 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/MeshTransportOptionsValidatorTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Shouldly; using Xunit; @@ -10,10 +11,35 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// trace. Absences are the hardest class of fault to attribute in the field, so the whole point /// of these tests is that the host refuses to start instead. /// +/// +/// Role-dependent facts run in because the validator +/// resolves roles through RoleParser.ResolveEffectiveRoles, which reads +/// OTOPCUA_ROLES when Cluster:Roles is empty — a process-wide env var, so the +/// whole class is serialized against the other role-reading suites. +/// +[Collection(RoleEnvironmentCollection.Name)] public class MeshTransportOptionsValidatorTests { - private static ValidateOptionsResult Validate(MeshTransportOptions o) => - new MeshTransportOptionsValidator().Validate(MeshTransportOptions.SectionName, o); + private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles) + { + var pairs = new Dictionary(); + for (var i = 0; i < roles.Length; i++) + { + pairs[$"Cluster:Roles:{i}"] = roles[i]; + } + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build(); + + return new MeshTransportOptionsValidator(configuration) + .Validate(MeshTransportOptions.SectionName, o); + } + + private static MeshTransportOptions WellFormedGrpc() => new() + { + Mode = MeshTransportOptions.ModeGrpc, + GrpcPsk = "not-a-real-key", + CentralGrpcEndpoints = ["http://central-1:4055", "https://central-2:4055"], + }; [Fact] public void Default_options_are_valid() @@ -26,10 +52,23 @@ public class MeshTransportOptionsValidatorTests [Fact] public void Unknown_mode_fails() { - var result = Validate(new MeshTransportOptions { Mode = "grpc" }); + var result = Validate(new MeshTransportOptions { Mode = "receptionist" }); result.Failed.ShouldBeTrue(); - result.FailureMessage.ShouldContain("grpc"); + result.FailureMessage.ShouldContain("receptionist"); + } + + [Fact] + public void Unknown_mode_message_names_all_three_accepted_values() + { + // An operator who mistypes the mode gets the full menu, not a partial one — Grpc became a + // legal value in Phase 0 and an error that omitted it would send them back to ClusterClient. + var result = Validate(new MeshTransportOptions { Mode = "receptionist" }); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain(MeshTransportOptions.ModeDps); + result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient); + result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc); } [Fact] @@ -135,4 +174,210 @@ public class MeshTransportOptionsValidatorTests ], }).Succeeded.ShouldBeTrue(); } + + // ---- Grpc mode (ClusterClient→gRPC migration, Phase 0) -------------------------------------- + + [Fact] + public void Grpc_is_an_accepted_mode() + { + Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Grpc_mode_matching_is_case_insensitive() + { + var options = WellFormedGrpc(); + options.Mode = "grpc"; + + Validate(options, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Grpc_driver_node_without_a_psk_fails() + { + var options = WellFormedGrpc(); + options.GrpcPsk = null; + + var result = Validate(options, "driver", "cluster-SITE-A"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("MeshTransport:GrpcPsk"); + result.FailureMessage.ShouldContain("MeshTransport__GrpcPsk"); + } + + [Fact] + public void Grpc_driver_node_with_an_empty_psk_fails() + { + // Empty is the shape an unset environment variable actually produces, and it is exactly as + // fatal as null — a blank bearer authenticates nothing. + var options = WellFormedGrpc(); + options.GrpcPsk = " "; + + Validate(options, "driver", "cluster-SITE-A").Failed.ShouldBeTrue(); + } + + [Fact] + public void The_psk_value_is_never_echoed_in_a_failure_message() + { + // The whole point of a validator that reads a secret is that its diagnostics are safe to + // paste into a ticket. Naming the key is the contract; printing it is a leak. + var options = WellFormedGrpc(); + options.GrpcPsk = "super-secret-mesh-psk"; + options.CentralGrpcEndpoints = ["central-1:4055"]; + + var result = Validate(options, "driver", "cluster-SITE-A"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotContain("super-secret-mesh-psk"); + } + + [Fact] + public void Grpc_driver_node_without_central_endpoints_fails() + { + var options = WellFormedGrpc(); + options.CentralGrpcEndpoints = []; + + var result = Validate(options, "driver", "cluster-SITE-A"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints"); + } + + [Fact] + public void A_central_endpoint_that_is_not_an_http_url_fails() + { + var options = WellFormedGrpc(); + options.CentralGrpcEndpoints = ["central-1:4055"]; + + var result = Validate(options, "driver", "cluster-SITE-A"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("central-1:4055"); + result.FailureMessage.ShouldContain("http://"); + } + + [Fact] + public void One_bad_central_endpoint_among_good_ones_still_fails() + { + var options = WellFormedGrpc(); + options.CentralGrpcEndpoints = ["http://central-1:4055", "akka.tcp://otopcua@central-2:4053"]; + + var result = Validate(options, "driver", "cluster-SITE-A"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("akka.tcp://otopcua@central-2:4053"); + } + + [Fact] + public void Grpc_driver_node_with_a_non_positive_dispatch_timeout_fails() + { + var options = WellFormedGrpc(); + options.DispatchTimeoutSeconds = 0; + + var result = Validate(options, "driver", "cluster-SITE-A"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("MeshTransport:DispatchTimeoutSeconds"); + } + + [Fact] + public void Grpc_driver_node_with_a_non_positive_ack_timeout_fails() + { + var options = WellFormedGrpc(); + options.AckTimeoutSeconds = -1; + + var result = Validate(options, "driver", "cluster-SITE-A"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("MeshTransport:AckTimeoutSeconds"); + } + + [Fact] + public void Grpc_admin_only_node_needs_none_of_the_node_side_keys() + { + // The node-side surface is what a DRIVER uses to dial central and post its ack. An admin-only + // central node dials nodes instead and resolves per-cluster keys at runtime, so requiring + // these here would make an admin node carry a PSK and an endpoint list it never reads. + Validate( + new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, + "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Grpc_role_less_node_needs_none_of_the_node_side_keys() + { + // Every in-process test host and every single-mesh node has no declared role. Requiring the + // Grpc surface of them would turn a dark-switch value into a tree-wide config obligation. + Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }).Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Roles_come_from_OTOPCUA_ROLES_when_Cluster_Roles_is_empty() + { + // Roles-source divergence (Gitea #532): a node configured only through the env var is still a + // driver node in Akka. A validator reading Cluster:Roles alone would exempt the exact node + // that has to dial central, and its acks would be rejected unauthenticated at runtime. + using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A")) + { + var options = WellFormedGrpc(); + options.GrpcPsk = null; + + Validate(options).Failed.ShouldBeTrue(); + } + } + + [Fact] + public void Dps_mode_requires_none_of_the_new_Grpc_keys() + { + // The no-new-obligations invariant: adding a third mode must not make any existing + // deployment's configuration invalid. A Dps driver node that sets nothing new still passes. + Validate( + new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps }, + "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void ClusterClient_mode_requires_none_of_the_new_Grpc_keys() + { + Validate( + new MeshTransportOptions + { + Mode = MeshTransportOptions.ModeClusterClient, + CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"], + }, + "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void ClusterPsks_defaults_to_an_empty_case_insensitive_map() + { + // Production leaves the map empty and the runtime provider falls back to the Secrets store; + // the rig populates it. Case-insensitivity matters because a ClusterId reaches this lookup + // from three sources — a role suffix, a DB column and an env key — that do not agree on case. + var options = new MeshTransportOptions(); + + options.ClusterPsks.ShouldBeEmpty(); + options.ClusterPsks["MAIN"] = "rig-key"; + options.ClusterPsks.ContainsKey("main").ShouldBeTrue(); + } + + [Fact] + public void ClusterPsks_stays_case_insensitive_after_binding_configuration() + { + // Asserting the comparer on a fresh instance proves nothing about the shape that reaches + // production: the options binder could construct its own Dictionary and drop the comparer + // silently, and the symptom would be a rig lookup that misses on a ClusterId spelled the way + // the DB spells it. Bind for real and check the comparer survived. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["MeshTransport:ClusterPsks:SITE-A"] = "rig-key", + }) + .Build(); + + var options = new MeshTransportOptions(); + configuration.GetSection(MeshTransportOptions.SectionName).Bind(options); + + options.ClusterPsks["site-a"].ShouldBe("rig-key"); + } } 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 6d5b018c..4aff26d5 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/SplitTopologyTransportValidatorTests.cs @@ -52,9 +52,58 @@ public class SplitTopologyTransportValidatorTests result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:Mode"); result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient); + result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc); result.FailureMessage.ShouldContain("SITE-A"); } + [Fact] + public void Cluster_role_driver_node_on_Grpc_mesh_transport_passes() + { + // The migration window: Grpc is the transport this rule exists to arrive at, so a rig node + // flipped to it must boot. Without this relaxation the very first Grpc rollout step — setting + // the mode on a split-topology node — is a host-start failure. + 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() + { + // Widening the rule from one accepted value to two must not widen it to "anything that is not + // Dps" — a typo'd mode is still a mode that delivers nothing across the boundary. + var result = Validate( + meshMode: "receptionist", + 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() {