feat(cluster): refuse to boot a split-topology node on a DPS transport mode

Per-cluster mesh Phase 6 (Task 5). A node carrying a cluster-{ClusterId}
role (RoleParser.IsClusterRole) is in the split topology, where the
Phase 2/3/5 DPS dark-switch branches deliver nothing across the mesh
boundary. SplitTopologyTransportValidator fails host start unless such a
node uses the mesh-crossing transports: MeshTransport:Mode=ClusterClient
always; Telemetry:Mode=Grpc if it has the driver role; TelemetryDial:Mode
=Grpc if it has the admin role. It is a no-op for any node with no
cluster-role (legacy / single-mesh / test). Roles + the three modes are
cross-read from IConfiguration, mirroring ConfigSourceOptionsValidator.
Registered via AddValidatedOptions on MeshTransportOptions with
ValidateOnStart, alongside the sibling validators.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 02:13:47 -04:00
parent d98b32640b
commit a886d5e6e0
3 changed files with 312 additions and 0 deletions
@@ -56,6 +56,14 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions<TelemetryDialOptions, TelemetryDialOptionsValidator>(
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<MeshTransportOptions, SplitTopologyTransportValidator>(
configuration, MeshTransportOptions.SectionName);
services.AddSingleton<IClusterRoleInfo, ClusterRoleInfo>();
return services;
@@ -0,0 +1,117 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Fails the host at startup when a node configured for the <b>split topology</b> (per-cluster
/// mesh, Phase 6) is left on a DistributedPubSub transport mode that cannot cross the mesh
/// boundary.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>cluster-{ClusterId}</c> role
/// (<see cref="RoleParser.IsClusterRole"/>); those roles exist only post-Phase-6. A node that
/// carries one <b>must</b> 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.
/// </para>
/// <para>
/// <b>The rule is conditional and must stay that way.</b> It fires only when this node's
/// <c>Cluster:Roles</c> 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.
/// </para>
/// <para>
/// <b>Not validated here:</b> <c>ConfigSource:Mode</c>. Phase 4's
/// <see cref="ConfigSourceOptionsValidator"/> already requires <c>FetchAndCache</c> on a
/// driver-only node, and a fused node legitimately stays <c>Direct</c> — duplicating that here
/// would only risk the two rules drifting apart.
/// </para>
/// </remarks>
public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTransportOptions>
{
private readonly IConfiguration _configuration;
/// <summary>
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
/// constructor dependency is safe here. <see cref="IConfiguration"/> — not
/// <c>IClusterRoleInfo</c> — is the source of this node's roles and the three transport modes:
/// <c>IClusterRoleInfo</c>'s implementation needs the <c>ActorSystem</c>, which does not exist
/// yet at <c>ValidateOnStart</c> time. Reading the modes from configuration too (rather than
/// from three injected <c>IOptions</c>) mirrors how the sibling validators cross-read
/// <c>Cluster:Roles</c>, and avoids a service-resolution ordering dependency at validation
/// time.
/// </summary>
public SplitTopologyTransportValidator(IConfiguration configuration)
{
_configuration = configuration;
}
/// <inheritdoc />
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<string[]>() ?? Array.Empty<string>();
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<string>();
// 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));
}
}