feat(mesh): ConfigSource/ConfigServe options + validator (Phase 3 dark switch)
ConfigSource:Mode = Direct (default, read central SQL) | FetchAndCache (fetch the artifact from central over gRPC, read LocalDb). The validator fails host start on a FetchAndCache shape that cannot fetch — no endpoints, a non-http(s) endpoint, no shared key, or a non-positive timeout — because each otherwise surfaces as a silent absence (a deploy that never applies). ConfigServe (the central serve surface) takes a plain Configure: a 0 port is a valid "disabled". Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Node-side selection of where a driver reads its deployed configuration (per-cluster mesh
|
||||
/// Phase 3). The dark switch that lets a driver node stop reading <c>Deployment.ArtifactBlob</c>
|
||||
/// from central SQL and instead fetch the artifact from central over gRPC and read it from the
|
||||
/// pair-local LocalDb cache.
|
||||
/// </summary>
|
||||
public sealed class ConfigSourceOptions
|
||||
{
|
||||
/// <summary>Configuration section name.</summary>
|
||||
public const string SectionName = "ConfigSource";
|
||||
|
||||
/// <summary>Read config directly from central SQL — today's behaviour, and the default.</summary>
|
||||
public const string ModeDirect = "Direct";
|
||||
|
||||
/// <summary>Fetch the artifact from central over gRPC, cache it in LocalDb, and read from there.</summary>
|
||||
public const string ModeFetchAndCache = "FetchAndCache";
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ModeDirect"/> (default) or <see cref="ModeFetchAndCache"/>. Any other value
|
||||
/// fails host start.
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = ModeDirect;
|
||||
|
||||
/// <summary>
|
||||
/// Central artifact-gRPC base addresses, e.g. <c>http://central-1:4055</c> (h2c ⇒ the
|
||||
/// <c>http</c> scheme). Tried in order for failover. Required under
|
||||
/// <see cref="ModeFetchAndCache"/>; ignored under <see cref="ModeDirect"/>.
|
||||
/// </summary>
|
||||
public string[] CentralFetchEndpoints { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Shared bearer key; must equal central's <see cref="ConfigServeOptions.ApiKey"/>. Supply via
|
||||
/// the environment (<c>ConfigSource__ApiKey</c>) — never commit it. Required under
|
||||
/// <see cref="ModeFetchAndCache"/>.
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-fetch deadline in seconds. Must be positive under <see cref="ModeFetchAndCache"/>.</summary>
|
||||
public int FetchTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Central-side artifact-serve surface (per-cluster mesh Phase 3). Configures the dedicated h2c
|
||||
/// listener + shared bearer key the <c>DeploymentArtifactService</c> is exposed behind. Only
|
||||
/// admin-role nodes (which hold the central SQL connection) serve.
|
||||
/// </summary>
|
||||
public sealed class ConfigServeOptions
|
||||
{
|
||||
/// <summary>Configuration section name.</summary>
|
||||
public const string SectionName = "ConfigServe";
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated HTTP/2-only (h2c) listener port for the artifact gRPC service. <c>0</c> (the
|
||||
/// default) disables it — nothing is bound. Must differ from the main HTTP port and from
|
||||
/// <c>LocalDb:SyncListenPort</c> (h2c cannot share a cleartext HTTP/1 port).
|
||||
/// </summary>
|
||||
public int GrpcListenPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared bearer key the serve-side interceptor checks (constant-time, fail-closed: an unset
|
||||
/// key rejects every call). Supply via the environment (<c>ConfigServe__ApiKey</c>) — never
|
||||
/// commit it.
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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>
|
||||
{
|
||||
/// <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}).");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(string.Join(" ", errors));
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ public static class ServiceCollectionExtensions
|
||||
services.AddValidatedOptions<MeshTransportOptions, MeshTransportOptionsValidator>(
|
||||
configuration, MeshTransportOptions.SectionName);
|
||||
|
||||
// Per-cluster mesh Phase 3: where a driver reads its config (Direct SQL, or fetch-and-cache
|
||||
// over gRPC). Validated at startup for the same reason — a FetchAndCache node with no endpoint
|
||||
// or no key produces silence, not an error. ConfigServe (the central serve surface) needs no
|
||||
// cross-field validation: a 0 port is a legitimate "disabled", so a plain Configure suffices.
|
||||
services.AddValidatedOptions<ConfigSourceOptions, ConfigSourceOptionsValidator>(
|
||||
configuration, ConfigSourceOptions.SectionName);
|
||||
services.Configure<ConfigServeOptions>(configuration.GetSection(ConfigServeOptions.SectionName));
|
||||
|
||||
services.AddSingleton<IClusterRoleInfo, ClusterRoleInfo>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A <c>FetchAndCache</c> node with no endpoint or no key does not error — it simply never
|
||||
/// applies a deployment, and the deploy times out naming a node that is otherwise healthy. These
|
||||
/// tests make that misconfiguration a loud host-start failure instead.
|
||||
/// </summary>
|
||||
public class ConfigSourceOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(ConfigSourceOptions o) =>
|
||||
new ConfigSourceOptionsValidator().Validate(ConfigSourceOptions.SectionName, o);
|
||||
|
||||
private static ConfigSourceOptions ValidFetch() => new()
|
||||
{
|
||||
Mode = ConfigSourceOptions.ModeFetchAndCache,
|
||||
CentralFetchEndpoints = ["http://central-1:4055", "http://central-2:4055"],
|
||||
ApiKey = "shared-key",
|
||||
FetchTimeoutSeconds = 30,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Default_options_are_valid()
|
||||
{
|
||||
// The default is Direct with everything empty — the pre-Phase-3 behaviour. A node that never
|
||||
// sets this section must validate, which is the premise of the dark switch.
|
||||
Validate(new ConfigSourceOptions()).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_mode_fails()
|
||||
{
|
||||
var result = Validate(new ConfigSourceOptions { Mode = "cache" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("cache");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode_matching_is_case_insensitive()
|
||||
{
|
||||
Validate(new ConfigSourceOptions
|
||||
{
|
||||
Mode = "fetchandcache",
|
||||
CentralFetchEndpoints = ["http://central-1:4055"],
|
||||
ApiKey = "k",
|
||||
}).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchAndCache_with_no_endpoints_fails()
|
||||
{
|
||||
var o = ValidFetch();
|
||||
o.CentralFetchEndpoints = [];
|
||||
|
||||
var result = Validate(o);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.CentralFetchEndpoints));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchAndCache_with_a_non_http_endpoint_fails()
|
||||
{
|
||||
var o = ValidFetch();
|
||||
o.CentralFetchEndpoints = ["central-1:4055"];
|
||||
|
||||
var result = Validate(o);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("central-1:4055");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchAndCache_with_an_empty_key_fails()
|
||||
{
|
||||
var o = ValidFetch();
|
||||
o.ApiKey = "";
|
||||
|
||||
var result = Validate(o);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.ApiKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchAndCache_with_a_non_positive_timeout_fails()
|
||||
{
|
||||
var o = ValidFetch();
|
||||
o.FetchTimeoutSeconds = 0;
|
||||
|
||||
var result = Validate(o);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.FetchTimeoutSeconds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_fully_populated_FetchAndCache_config_is_valid()
|
||||
{
|
||||
Validate(ValidFetch()).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Direct_ignores_the_empty_fetch_surface()
|
||||
{
|
||||
// Under Direct the endpoints/key are never read, so their emptiness must not fail validation —
|
||||
// otherwise every admin-only and every Direct node would be forced to carry fetch config.
|
||||
Validate(new ConfigSourceOptions
|
||||
{
|
||||
Mode = ConfigSourceOptions.ModeDirect,
|
||||
CentralFetchEndpoints = [],
|
||||
ApiKey = "",
|
||||
FetchTimeoutSeconds = 0,
|
||||
}).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user