using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; namespace ZB.MOM.WW.OtOpcUa.Cluster; /// /// Node-side selection of the transport a node's live-telemetry stream is carried over /// (per-cluster mesh Phase 5). The dark switch that lets a node stop publishing telemetry over /// the mesh-wide DistributedPubSub topic and instead serve it over a dedicated gRPC stream that /// does not require sharing a gossip ring with whoever is watching. /// public sealed class TelemetryOptions { /// Configuration section name. public const string SectionName = "Telemetry"; /// Publish telemetry over the mesh-wide DistributedPubSub topic — the default. public const string ModeDps = "Dps"; /// Serve telemetry over a dedicated gRPC stream. public const string ModeGrpc = "Grpc"; /// /// (default) or . Any other value fails host /// start. /// public string Mode { get; set; } = ModeDps; /// /// Dedicated gRPC listen port for the telemetry stream. 0 (the default) disables it — /// nothing is bound. Required on a driver-role node under . /// public int GrpcListenPort { get; set; } /// /// Shared bearer key the serve-side interceptor checks. Supply via the environment /// (Telemetry__ApiKey) — never commit it. Required under on any /// roled node — fail-closed against hosting an un-keyed telemetry surface. /// public string ApiKey { get; set; } = string.Empty; } /// /// Central-side selection of how central dials a node for live telemetry (per-cluster mesh /// Phase 5). Mirrors 's mode but carries the dial-side knobs /// (contact refresh cadence, per-call timeout) instead of a listen port. /// public sealed class TelemetryDialOptions { /// Configuration section name. public const string SectionName = "TelemetryDial"; /// Read telemetry from the mesh-wide DistributedPubSub topic — the default. public const string ModeDps = "Dps"; /// Dial nodes' dedicated gRPC telemetry streams. public const string ModeGrpc = "Grpc"; /// /// (default) or . Any other value fails host /// start. /// public string Mode { get; set; } = ModeDps; /// /// Shared bearer key; must equal a dialled node's . /// Supply via the environment (TelemetryDial__ApiKey) — never commit it. Required /// under . /// public string ApiKey { get; set; } = string.Empty; /// How often central refreshes its set of dialable node contacts, in seconds. public int ContactRefreshSeconds { get; set; } = 60; /// /// Deadline, in seconds, for a gRPC telemetry dial to deliver its first event. A live node answers a /// fresh subscribe at once (it replays its health/resilience snapshots), so a dial still silent at the /// deadline is one that never connected — it is cancelled and re-dialled. Bounds establishment only; /// an established stream is kept alive by HTTP/2 keepalive pings, not by this value. Values below 1 /// are treated as 1. /// public int CallTimeoutSeconds { get; set; } = 30; } /// /// Fails the host at startup on a shape that would leave a node /// unable to serve its live-telemetry stream. /// /// /// Like , every fault caught here otherwise surfaces as /// an absence — a telemetry consumer that simply never sees data from this node, with no /// stack trace and no failing node to point at; refusing to start is cheaper to diagnose. /// public sealed class TelemetryOptionsValidator : IValidateOptions { private readonly EffectiveRoles _effectiveRoles; /// /// DI-constructed by AddValidatedOptions (a plain AddSingleton), so a /// constructor dependency is safe here. This validator's only use of configuration is this /// node's roles, so it takes — the single role authority /// — directly rather than . /// /// The node's resolved roles, the single role authority. public TelemetryOptionsValidator(EffectiveRoles effectiveRoles) { _effectiveRoles = effectiveRoles; } /// Validates the telemetry options against this node's roles. /// The named options instance, or for the default. /// The options to validate. /// Success, or a failure listing every invalid setting. public ValidateOptionsResult Validate(string? name, TelemetryOptions options) { ArgumentNullException.ThrowIfNull(options); var errors = new List(); var isDps = string.Equals(options.Mode, TelemetryOptions.ModeDps, StringComparison.OrdinalIgnoreCase); var isGrpc = string.Equals(options.Mode, TelemetryOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase); if (!isDps && !isGrpc) { errors.Add( $"{TelemetryOptions.SectionName}:{nameof(TelemetryOptions.Mode)} is '{options.Mode}'. " + $"Expected '{TelemetryOptions.ModeDps}' or '{TelemetryOptions.ModeGrpc}'."); } // 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', still satisfies the // Grpc-mode requirements below. var isDriver = _effectiveRoles.HasDriver; if (isGrpc) { if (isDriver && options.GrpcListenPort <= 0) { errors.Add( $"Cluster:Roles has 'driver' and {TelemetryOptions.SectionName}:" + $"{nameof(TelemetryOptions.Mode)} is '{TelemetryOptions.ModeGrpc}', but " + $"{TelemetryOptions.SectionName}:{nameof(TelemetryOptions.GrpcListenPort)} is " + $"{options.GrpcListenPort}. A driver node in Grpc telemetry mode must set " + $"{TelemetryOptions.SectionName}:{nameof(TelemetryOptions.GrpcListenPort)}."); } if (_effectiveRoles.Gossiped.Count > 0 && string.IsNullOrEmpty(options.ApiKey)) { errors.Add( $"{TelemetryOptions.SectionName}:{nameof(TelemetryOptions.ApiKey)} is empty. Under " + $"{TelemetryOptions.ModeGrpc} the shared bearer key is the whole authentication " + "boundary to this node's telemetry surface; without it every dial is rejected. " + $"Supply it via the environment ({TelemetryOptions.SectionName}__ApiKey)."); } } return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(string.Join(" ", errors)); } } /// /// Fails the host at startup on a shape that would leave /// central unable to dial a node's live-telemetry stream. /// public sealed class TelemetryDialOptionsValidator : IValidateOptions { /// Validates the telemetry dial options. /// The named options instance, or for the default. /// The options to validate. /// Success, or a failure listing every invalid setting. public ValidateOptionsResult Validate(string? name, TelemetryDialOptions options) { ArgumentNullException.ThrowIfNull(options); var errors = new List(); var isDps = string.Equals( options.Mode, TelemetryDialOptions.ModeDps, StringComparison.OrdinalIgnoreCase); var isGrpc = string.Equals( options.Mode, TelemetryDialOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase); if (!isDps && !isGrpc) { errors.Add( $"{TelemetryDialOptions.SectionName}:{nameof(TelemetryDialOptions.Mode)} is " + $"'{options.Mode}'. Expected '{TelemetryDialOptions.ModeDps}' or " + $"'{TelemetryDialOptions.ModeGrpc}'."); } if (isGrpc && string.IsNullOrEmpty(options.ApiKey)) { errors.Add( $"{TelemetryDialOptions.SectionName}:{nameof(TelemetryDialOptions.ApiKey)} is empty. " + $"Under {TelemetryDialOptions.ModeGrpc} the shared bearer key is the whole " + "authentication boundary to a node's telemetry surface; without it every dial is " + $"rejected. Supply it via the environment ({TelemetryDialOptions.SectionName}__ApiKey)."); } return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(string.Join(" ", errors)); } }