d630a7e267
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
117 lines
5.5 KiB
C#
117 lines
5.5 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
|
|
|
/// <summary>
|
|
/// Fails the host at startup on a <see cref="ConfigSourceOptions"/> shape that would leave a
|
|
/// driver node unable to fetch its configuration.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Like <see cref="MeshTransportOptionsValidator"/>, every fault caught here otherwise surfaces as
|
|
/// an <i>absence</i> — 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.
|
|
/// </remarks>
|
|
public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSourceOptions>
|
|
{
|
|
private readonly IConfiguration _configuration;
|
|
|
|
/// <summary>
|
|
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
|
/// constructor dependency is safe here. <see cref="IConfiguration"/> — not
|
|
/// <c>IClusterRoleInfo</c> — is the source of this node's roles: <c>IClusterRoleInfo</c>'s
|
|
/// implementation needs the <c>ActorSystem</c>, which does not exist yet at
|
|
/// <c>ValidateOnStart</c> time.
|
|
/// </summary>
|
|
public ConfigSourceOptionsValidator(IConfiguration configuration)
|
|
{
|
|
_configuration = configuration;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
var errors = new List<string>();
|
|
|
|
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<string[]>() ?? Array.Empty<string>();
|
|
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));
|
|
}
|
|
}
|