From 9d1e60c00ce97c6b43945a64b0b03b138a068bf6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 19:03:39 -0400 Subject: [PATCH] feat(mesh): ConfigSource/ConfigServe options + validator (Phase 3 dark switch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ConfigSourceOptions.cs | 67 ++++++++++ .../ConfigSourceOptionsValidator.cs | 83 ++++++++++++ .../ServiceCollectionExtensions.cs | 8 ++ .../ConfigSourceOptionsValidatorTests.cs | 120 ++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptions.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptions.cs new file mode 100644 index 00000000..066c17ae --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptions.cs @@ -0,0 +1,67 @@ +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +/// +/// 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 Deployment.ArtifactBlob +/// from central SQL and instead fetch the artifact from central over gRPC and read it from the +/// pair-local LocalDb cache. +/// +public sealed class ConfigSourceOptions +{ + /// Configuration section name. + public const string SectionName = "ConfigSource"; + + /// Read config directly from central SQL — today's behaviour, and the default. + public const string ModeDirect = "Direct"; + + /// Fetch the artifact from central over gRPC, cache it in LocalDb, and read from there. + public const string ModeFetchAndCache = "FetchAndCache"; + + /// + /// (default) or . Any other value + /// fails host start. + /// + public string Mode { get; set; } = ModeDirect; + + /// + /// Central artifact-gRPC base addresses, e.g. http://central-1:4055 (h2c ⇒ the + /// http scheme). Tried in order for failover. Required under + /// ; ignored under . + /// + public string[] CentralFetchEndpoints { get; set; } = []; + + /// + /// Shared bearer key; must equal central's . Supply via + /// the environment (ConfigSource__ApiKey) — never commit it. Required under + /// . + /// + public string ApiKey { get; set; } = string.Empty; + + /// Per-fetch deadline in seconds. Must be positive under . + public int FetchTimeoutSeconds { get; set; } = 30; +} + +/// +/// Central-side artifact-serve surface (per-cluster mesh Phase 3). Configures the dedicated h2c +/// listener + shared bearer key the DeploymentArtifactService is exposed behind. Only +/// admin-role nodes (which hold the central SQL connection) serve. +/// +public sealed class ConfigServeOptions +{ + /// Configuration section name. + public const string SectionName = "ConfigServe"; + + /// + /// Dedicated HTTP/2-only (h2c) listener port for the artifact gRPC service. 0 (the + /// default) disables it — nothing is bound. Must differ from the main HTTP port and from + /// LocalDb:SyncListenPort (h2c cannot share a cleartext HTTP/1 port). + /// + public int GrpcListenPort { get; set; } + + /// + /// Shared bearer key the serve-side interceptor checks (constant-time, fail-closed: an unset + /// key rejects every call). Supply via the environment (ConfigServe__ApiKey) — never + /// commit it. + /// + public string ApiKey { get; set; } = string.Empty; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs new file mode 100644 index 00000000..9519a622 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs @@ -0,0 +1,83 @@ +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 +{ + /// + 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})."); + } + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(string.Join(" ", errors)); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs index 1363191e..4c9e567e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs @@ -40,6 +40,14 @@ public static class ServiceCollectionExtensions services.AddValidatedOptions( 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( + configuration, ConfigSourceOptions.SectionName); + services.Configure(configuration.GetSection(ConfigServeOptions.SectionName)); + services.AddSingleton(); return services; diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs new file mode 100644 index 00000000..064e68e8 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; + +/// +/// A FetchAndCache 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. +/// +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(); + } +}