using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
///
/// Fails the host at startup on a shape that would leave
/// commands silently undelivered.
///
///
/// Every fault caught here otherwise surfaces as an absence — 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.
///
public sealed class MeshTransportOptionsValidator : IValidateOptions
{
private readonly IConfiguration _configuration;
private readonly EffectiveRoles _effectiveRoles;
///
/// DI-constructed by AddValidatedOptions (a plain AddSingleton), so constructor
/// dependencies are safe here. is still needed for the
/// ConfigServe:GrpcListenPort cross-read (the admin-side obligation below) —
/// is the single role authority this rule reads
/// roles through instead of re-deriving them.
///
/// The application configuration, read for ConfigServe:GrpcListenPort.
/// This node's resolved effective roles.
public MeshTransportOptionsValidator(IConfiguration configuration, EffectiveRoles effectiveRoles)
{
_configuration = configuration;
_effectiveRoles = effectiveRoles;
}
/// Validates the for this node's roles.
/// The options instance name (unused).
/// The options to validate.
/// A success result, or a failure listing every violated rule.
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var errors = new List();
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-}.");
}
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 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));
}
}