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