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:
Joseph Doherty
2026-07-22 19:03:39 -04:00
parent b3cb0f4a54
commit 9d1e60c00c
4 changed files with 278 additions and 0 deletions
@@ -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();
}
}