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 a /// driver node unable to fetch its configuration. /// /// /// Like , every fault caught here otherwise surfaces as /// an absence — a deployment that never applies because the node has no endpoint to fetch /// from or no key to authenticate with. An absence has no stack trace and no failing node to point /// at; refusing to start is cheaper to diagnose. /// public sealed class ConfigSourceOptionsValidator : IValidateOptions { private readonly IConfiguration _configuration; /// /// DI-constructed by AddValidatedOptions (a plain AddSingleton), so a /// constructor dependency is safe here. — not /// IClusterRoleInfo — is the source of this node's roles: IClusterRoleInfo's /// implementation needs the ActorSystem, which does not exist yet at /// ValidateOnStart time. /// public ConfigSourceOptionsValidator(IConfiguration configuration) { _configuration = configuration; } /// public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options) { ArgumentNullException.ThrowIfNull(options); var errors = new List(); var isDirect = string.Equals(options.Mode, ConfigSourceOptions.ModeDirect, StringComparison.OrdinalIgnoreCase); var isFetch = string.Equals( options.Mode, ConfigSourceOptions.ModeFetchAndCache, StringComparison.OrdinalIgnoreCase); if (!isDirect && !isFetch) { errors.Add( $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)} is " + $"'{options.Mode}'. Expected '{ConfigSourceOptions.ModeDirect}' or " + $"'{ConfigSourceOptions.ModeFetchAndCache}'."); } // The fetch surface is only load-bearing under FetchAndCache. Requiring it under the default // would make the section mandatory everywhere for a feature that is switched off — including on // admin-only nodes, which never fetch. if (isFetch) { if (options.CentralFetchEndpoints.Length == 0) { errors.Add( $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.CentralFetchEndpoints)} " + "is empty. Under FetchAndCache a driver node cannot reach central to fetch its " + "configuration, and every deployment would time out naming a node that is running " + "perfectly."); } foreach (var endpoint in options.CentralFetchEndpoints) { if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { errors.Add( $"Central fetch endpoint '{endpoint}' must start with 'http://' (h2c) or " + "'https://' — it is a gRPC base address (scheme://host:port), not a bare " + "host:port."); } } if (string.IsNullOrEmpty(options.ApiKey)) { errors.Add( $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.ApiKey)} is empty. " + "Under FetchAndCache the shared bearer key is the whole authentication boundary to " + "central's artifact service; without it every fetch is rejected. Supply it via the " + "environment (ConfigSource__ApiKey)."); } if (options.FetchTimeoutSeconds <= 0) { errors.Add( $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.FetchTimeoutSeconds)} " + $"must be greater than 0 (was {options.FetchTimeoutSeconds})."); } } // Per-cluster mesh Phase 4: a driver-only node has no central ConfigDb connection to fall back // on, so Direct is only ever valid on a fused admin+driver node (admin holds the SQL // connection). Roles come from Cluster:Roles directly, not IClusterRoleInfo — see the ctor // remark. var roles = _configuration.GetSection("Cluster:Roles").Get() ?? Array.Empty(); var isDriver = Array.IndexOf(roles, "driver") >= 0; var isAdmin = Array.IndexOf(roles, "admin") >= 0; if (isDriver && !isAdmin && isDirect) { errors.Add( "Cluster:Roles is a driver-only node (has 'driver', not 'admin') but " + $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)} is 'Direct'. A " + "driver-only node has no central ConfigDb to read from — it must use 'FetchAndCache'. " + $"Set {ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)}=" + $"{ConfigSourceOptions.ModeFetchAndCache} (see docs/Configuration.md → ConfigSource)."); } return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(string.Join(" ", errors)); } }