826312befe
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.
177 lines
9.8 KiB
C#
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));
|
|
}
|
|
}
|