12 KiB
Configuration validation — current state: ScadaBridge
Repo: ~/Desktop/ScadaBridge. Stack: .NET 10, Akka.NET, Docker; solution
ZB.MOM.WW.ScadaBridge.slnx. All paths relative to repo root. Verified 2026-06-01.
ScadaBridge is the heaviest consumer — it has the most validation surface and the only pre-host preflight in the family:
- Four per-module
*OptionsValidator : IValidateOptions<T>(Cluster, Security, HealthMonitoring, AuditLog), each open-coding the sameList<string>accumulation, each wired through its module's bespokeAddXxxDI extension. - One raw-config, pre-Akka
StartupValidatorthat validates critical node/cluster keys before the actor system is built — the canonical motivation forConfigPreflight. Its thrown message is byte-compatible withConfigPreflight.ThrowIfInvalid().
1. Per-module options validators
All four follow the same shape: List<string> failures, a run of if (...) failures.Add(...),
and failures.Count > 0 ? Fail(failures) : Success (order varies). They are registered via
TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<T>, ...>()) so a misconfigured
section throws OptionsValidationException (with ValidateOnStart) or on first IOptions<T> resolve.
ClusterOptionsValidator
src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs:
:13—public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>.:28—var failures = new List<string>();.:30SeedNodes≥ 2 (→MinCount);:44SplitBrainResolverStrategy∈ {keep-oldest} (→OneOf, with the allowed set at:16–19);:52MinNrOfMembers == 1(→RequireThat);:59/:64/:69three positive-TimeSpanchecks (→PositiveTimeSpan);:74cross-fieldHeartbeatInterval < FailureDetectionThreshold(→RequireThat);:82DownIfAlonemust be true (→RequireThat).:91–93— thefailures.Count > 0 ? Fail : Successtail.- Wired:
src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:28–29—TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<ClusterOptions>, ClusterOptionsValidator>()).
SecurityOptionsValidator
src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs:
:32—public sealed class SecurityOptionsValidator : IValidateOptions<SecurityOptions>.:48—var failures = new List<string>();;:50requiredLdapServer,:58requiredLdapSearchBase(both →Required).JwtSigningKeyis intentionally not validated here (:24–30— it fails fast inJwtTokenService's constructor instead).:66–68—failures.Count == 0 ? Success : Fail(failures)tail.- Wired:
src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs:28–30—AddOptions<SecurityOptions>().ValidateOnStart()+TryAddEnumerable(...Singleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>()).
HealthMonitoringOptionsValidator
src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs:
:17—public sealed class HealthMonitoringOptionsValidator : IValidateOptions<HealthMonitoringOptions>.:26—var failures = new List<string>();;:28/:35/:42three positive-TimeSpanchecks (→PositiveTimeSpan);:49cross-fieldCentralOfflineTimeout >= OfflineTimeout(→RequireThat).:60–62— theCount > 0 ? Fail : Successtail.- Wired:
src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs:60–64— a private idempotentAddOptionsValidationdoesTryAddEnumerable(...Singleton<IValidateOptions<HealthMonitoringOptions>, HealthMonitoringOptionsValidator>()), called from all threeAdd*HealthMonitoring/AddCentralHealthAggregationentry points (:16,:29,:42).
AuditLogOptionsValidator
src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs:
:16—public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>.:35—var failures = new List<string>();;:37DefaultCapBytes > 0(→RequireThat);:44cross-fieldErrorCapBytes >= DefaultCapBytes(→RequireThat);:52RetentionDays∈ [30, 3650] (→RequireThat, bounds at:19–22);:59InboundMaxBytes∈ [8 KiB, 16 MiB] (→RequireThat, bounds at:25–28).:66–68— theCount == 0 ? Success : Failtail.- Wired:
src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs:65–68—AddOptions<AuditLogOptions>().Bind(config.GetSection(ConfigSectionName)).ValidateOnStart()+AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>(). This is the exactAddValidatedOptionstriple, spelled out.
Other modules bind options with no validator (
Communication,DataConnectionLayer,Transport,Notification*,ExternalSystemGateway,ManagementService,SiteCallAudit,DeploymentManager— allAddOptions().BindConfiguration(...)withoutValidateOnStartor a validator). They are candidates forAddValidatedOptionsonly if/when they grow validators; not part of this pass's adoption.
2. StartupValidator — raw-config, pre-Akka preflight
src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs:
:7—public static class StartupValidator;:11—public static void Validate(IConfiguration configuration).:13—var errors = new List<string>();. Reads raw keys offconfiguration(no binding)::16ScadaBridge:Node:Role∈ {Central,Site} (→Require(key, predicate, reason));:20Node:NodeHostnamerequired (→RequireValue);:23Node:RemotingPortparseable port 1–65535 (→RequirePort);:27Node:SiteIdrequired when role == Site (→When(role == "Site", ...));:30–41Database:ConfigurationDb/Security:LdapServer/Security:JwtSigningKeyrequired when role == Central (→When(role == "Central", ...));:43Cluster:SeedNodes≥ 2 entries (→ aRequire/custom rule over the bound list);:47–79Site-only rules:GrpcPortrange (:49),GrpcPort != RemotingPort(:58),Database:SiteDbPathrequired (:61), and seed-node-must-not-target-gRPC-port (:69–78) — all under aWhen(role == "Site", ...)block, withSeedNodePort(:90) as a domain helper that stays per-project.
:81–83— the throw:throw new InvalidOperationException( $"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}");- Called once, before the actor system is built:
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:39—StartupValidator.Validate(configuration);.
Message byte-compatibility with ConfigPreflight ✅
StartupValidator's throw (:81–83) and ConfigPreflight.ThrowIfInvalid()
(ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs:63–68) build the same
string:
- both prefix
"Configuration validation failed:\n"; - both join the failures with
"\n"; - both format each failure as
" - " + message.
The library deliberately copied this envelope so the migration is a behaviour-preserving swap:
same exception type (InvalidOperationException), same message bytes. The individual failure
messages are "<field> <reason>" (StartupValidator open-codes them; ConfigPreflight produces
them via the shared Checks primitives for the standardized rules — RequireValue → "<key> is required", RequirePort → "<key> must be between 1 and 65535 (was '<raw>')"). Rules that have
no shared primitive (role-set membership, gRPC-port-vs-remoting, seed-node-vs-gRPC-port) keep their
exact wording via Require(key, predicate, reason) and When(...).
3. Summary
| Surface | What exists | Shared-lib mapping |
|---|---|---|
| Cluster validator | ClusterOptionsValidator : IValidateOptions<ClusterOptions> |
→ OptionsValidatorBase<ClusterOptions> |
| Security validator | SecurityOptionsValidator : IValidateOptions<SecurityOptions> |
→ OptionsValidatorBase<SecurityOptions> |
| Health validator | HealthMonitoringOptionsValidator : IValidateOptions<HealthMonitoringOptions> |
→ OptionsValidatorBase<HealthMonitoringOptions> |
| Audit validator | AuditLogOptionsValidator : IValidateOptions<AuditLogOptions> |
→ OptionsValidatorBase<AuditLogOptions> |
| Failure accumulation (×4) | private List<string> + Count-based tail in each |
→ owned by base + ValidationBuilder |
| DI wiring (×4) | per-module TryAddEnumerable/AddSingleton + AddOptions().Bind().ValidateOnStart() |
→ AddValidatedOptions<T, TValidator> |
| Pre-host preflight | StartupValidator (raw config, pre-Akka, Program.cs:39) |
→ ConfigPreflight (byte-compatible message) |
Adoption plan → ZB.MOM.WW.Configuration
ScadaBridge is the heaviest adoption — five validation surfaces — but every change is behaviour-preserving.
Migrate the four module validators to the base:
- For each of
ClusterOptionsValidator,SecurityOptionsValidator,HealthMonitoringOptionsValidator,AuditLogOptionsValidator: change the declaration from: IValidateOptions<T>to: OptionsValidatorBase<T>, replaceValidate(string?, T)withprotected override void Validate(ValidationBuilder v, T o), delete theList<string> failuresand theCount-based tail, and re-express each rule onv:SeedNodes≥ 2 →v.MinCount(o.SeedNodes, 2, "ClusterOptions.SeedNodes");- strategy set →
v.OneOf(o.SplitBrainResolverStrategy, ["keep-oldest"], "..."); - positive durations →
v.PositiveTimeSpan(...); - required strings →
v.Required(...); - cross-field / bounds rules (
MinNrOfMembers == 1, heartbeat < threshold,ErrorCapBytes >= DefaultCapBytes, retention bounds, etc.) →v.RequireThat(condition, message)with the existing message strings preserved verbatim.
- Update each module's
ServiceCollectionExtensionsto register viaAddValidatedOptions<T, TValidator>(configuration, "<section>")instead of theAddOptions().Bind/BindConfiguration(...).ValidateOnStart()+AddSingleton/TryAddEnumerablepair (ClusterInfrastructure/ServiceCollectionExtensions.cs:28–29;Security/ServiceCollectionExtensions.cs:28–30;HealthMonitoring/ServiceCollectionExtensions.cs:60–64;AuditLog/ServiceCollectionExtensions.cs:65–68). Where a module usesTryAddEnumerablefor idempotency across multiple entry points (HealthMonitoring), keep an idempotency guard around the singleAddValidatedOptionscall.
Migrate StartupValidator → ConfigPreflight:
- Replace the body of
StartupValidator.Validate(IConfiguration)with aConfigPreflight.For(configuration)chain:.Require("ScadaBridge:Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'"),.RequireValue("ScadaBridge:Node:NodeHostname"),.RequirePort("ScadaBridge:Node:RemotingPort"),.When(role == "Site", p => p.RequireValue("ScadaBridge:Node:SiteId")),.When(role == "Central", p => p.RequireValue("ScadaBridge:Database:ConfigurationDb")...), the Site-only block (GrpcPortrange,GrpcPort != RemotingPort,SiteDbPath, seed-vs-gRPC-port), then.ThrowIfInvalid(). Keep theSeedNodePorthelper and the seed-node/gRPC-port custom rules asRequire(...)predicates — they have no shared primitive. - Verify the byte-compatibility (covered by the library's
ConfigPreflightTests): the swap preserves the exact"Configuration validation failed:\n - ..."message and theInvalidOperationExceptiontype. The call site (Program.cs:39) is unchanged.
Keep bespoke (unchanged):
- All options classes (
ClusterOptions,SecurityOptions,HealthMonitoringOptions,AuditLogOptions,NodeOptions) and every domain message/rule — split-brain strategy, Akka heartbeat/threshold relationship, audit retention bounds, gRPC-port-vs-remoting-port, seed-node topology. The library carries plumbing, not policy. - The no-validator modules (
Communication,DataConnectionLayer,Transport, etc.) — they have no validation to migrate; leave them until they grow validators.
Status: follow-on (tracked in ../GAPS.md). Heaviest of the three (five surfaces),
but every item is a behaviour-preserving swap — low risk, the preflight swap gated on the
byte-compatibility test.