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()
{