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)); } }