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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user