Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs
T
Joseph Doherty 826312befe docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
2026-10-15 06:14:58 -04:00

177 lines
9.8 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Fails the host at startup on a <see cref="MeshTransportOptions"/> shape that would leave
/// commands silently undelivered.
/// </summary>
/// <remarks>
/// Every fault caught here otherwise surfaces as an <i>absence</i> — a deployment that never
/// arrives, an alarm acknowledgement that does nothing, a node whose ack is dropped without a
/// trace. An absence has no stack trace and no failing node to point at, which makes it the
/// most expensive class of misconfiguration to diagnose in the field. Refusing to start is
/// cheaper for everyone.
/// </remarks>
public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTransportOptions>
{
private readonly IConfiguration _configuration;
private readonly EffectiveRoles _effectiveRoles;
/// <summary>
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
/// dependencies are safe here. <see cref="IConfiguration"/> is still needed for the
/// <c>ConfigServe:GrpcListenPort</c> cross-read (the admin-side obligation below) —
/// <see cref="EffectiveRoles"/> is the single role authority this rule reads
/// roles through instead of re-deriving them.
/// </summary>
/// <param name="configuration">The application configuration, read for <c>ConfigServe:GrpcListenPort</c>.</param>
/// <param name="effectiveRoles">This node's resolved effective roles.</param>
public MeshTransportOptionsValidator(IConfiguration configuration, EffectiveRoles effectiveRoles)
{
_configuration = configuration;
_effectiveRoles = effectiveRoles;
}
/// <summary>Validates the <see cref="MeshTransportOptions"/> for this node's roles.</summary>
/// <param name="name">The options instance name (unused).</param>
/// <param name="options">The options to validate.</param>
/// <returns>A success result, or a failure listing every violated rule.</returns>
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var errors = new List<string>();
var isDps = string.Equals(options.Mode, MeshTransportOptions.ModeDps, StringComparison.OrdinalIgnoreCase);
var isGrpc = string.Equals(options.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase);
// An unrecognised mode is refused, never defaulted. The retired 'ClusterClient' value lands
// here: a node left carrying it fails to start with the two remaining values named, rather
// than silently falling back to Dps — which on a split-topology node cannot cross the mesh
// boundary and would deliver nothing at all.
if (!isDps && !isGrpc)
{
errors.Add(
$"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is "
+ $"'{options.Mode}'. Expected '{MeshTransportOptions.ModeDps}' or "
+ $"'{MeshTransportOptions.ModeGrpc}'.");
}
if (options.ContactRefreshSeconds <= 0)
{
errors.Add(
$"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.ContactRefreshSeconds)} "
+ $"must be greater than 0 (was {options.ContactRefreshSeconds}). Central rebuilds its "
+ "node dispatch set on this interval; a non-positive value would leave that set "
+ "frozen at whatever it held when the node booted.");
}
// The first Grpc block below is node-side: the PSK a driver presents (and requires), the
// central endpoints it posts its ack to, and the two call deadlines (the admin-side rule that
// follows it is a separate scope). Scoped to the driver role because an admin-only central node
// dials nodes rather than being dialled, and resolves per-cluster keys at runtime through
// MeshTransportOptions.ClusterPsks / the Secrets store, so requiring a single GrpcPsk and an
// endpoint list of it would demand configuration it never reads. EffectiveRoles is the single
// role authority — its precedence already applies the OTOPCUA_ROLES
// fallback and its normalization already folds case, so a node configured only through the env
// var, or spelled 'Driver', is still the driver that has to dial central.
var isDriver = _effectiveRoles.HasDriver;
var isAdmin = _effectiveRoles.HasAdmin;
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-<clusterId>}.");
}
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}).");
}
}
// The one admin-side obligation, and it is cross-read rather than a member of this options
// class: central hosts CentralCommandService on the SAME dedicated h2c listener as the
// config-serve endpoint (ConfigServe:GrpcListenPort) — a second cleartext HTTP/2 port would
// buy nothing but another firewall rule. With that port unset the service is never mapped, so
// every node's deployment ack has nowhere to land, and nothing errors: the deployment simply
// times out naming nodes that applied perfectly. Owned here rather than by ConfigServe's own
// validation because the requirement comes from MeshTransport:Mode, not from config-serve — a
// Dps central node is legitimately portless.
if (isGrpc && isAdmin)
{
// Read as a string, the way SplitTopologyTransportValidator reads the modes:
// GetValue<int> THROWS on an unparseable value, and a validator whose whole job is to
// produce a readable failure must not be the thing that crashes on a typo. Missing,
// unparseable and non-positive all mean the same thing — nothing is bound — so they share
// one message.
var configServePort = _configuration[
$"{ConfigServeOptions.SectionName}:{nameof(ConfigServeOptions.GrpcListenPort)}"];
if (!int.TryParse(configServePort, out var port) || port <= 0)
{
errors.Add(
$"{ConfigServeOptions.SectionName}:"
+ $"{nameof(ConfigServeOptions.GrpcListenPort)} is not set to a positive port. "
+ $"Under {MeshTransportOptions.ModeGrpc} an admin node serves the central "
+ "command endpoint on that listener, and without it every node's deployment ack "
+ "has nowhere to land — the deploy would time out naming nodes that applied "
+ "perfectly. Supply it via the environment "
+ $"({ConfigServeOptions.SectionName}__"
+ $"{nameof(ConfigServeOptions.GrpcListenPort)}).");
}
}
return errors.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(string.Join(" ", errors));
}
}