192 lines
12 KiB
Markdown
192 lines
12 KiB
Markdown
# 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:
|
||
|
||
1. **Four per-module `*OptionsValidator : IValidateOptions<T>`** (Cluster, Security,
|
||
HealthMonitoring, AuditLog), each open-coding the same `List<string>` accumulation, each wired
|
||
through its module's bespoke `AddXxx` DI extension.
|
||
2. **One raw-config, pre-Akka `StartupValidator`** that validates critical node/cluster keys
|
||
*before* the actor system is built — the canonical motivation for `ConfigPreflight`. Its thrown
|
||
message is **byte-compatible** with `ConfigPreflight.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>();`.
|
||
- `:30` `SeedNodes` ≥ 2 (→ `MinCount`); `:44` `SplitBrainResolverStrategy` ∈ {`keep-oldest`}
|
||
(→ `OneOf`, with the allowed set at `:16–19`); `:52` `MinNrOfMembers == 1` (→ `RequireThat`);
|
||
`:59`/`:64`/`:69` three positive-`TimeSpan` checks (→ `PositiveTimeSpan`); `:74` cross-field
|
||
`HeartbeatInterval < FailureDetectionThreshold` (→ `RequireThat`); `:82` `DownIfAlone` must be
|
||
true (→ `RequireThat`).
|
||
- `:91–93` — the `failures.Count > 0 ? Fail : Success` tail.
|
||
- 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>();`; `:50` required `LdapServer`, `:58` required
|
||
`LdapSearchBase` (both → `Required`). `JwtSigningKey` is intentionally **not** validated here
|
||
(`:24–30` — it fails fast in `JwtTokenService`'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`/`:42` three positive-`TimeSpan` checks
|
||
(→ `PositiveTimeSpan`); `:49` cross-field `CentralOfflineTimeout >= OfflineTimeout` (→ `RequireThat`).
|
||
- `:60–62` — the `Count > 0 ? Fail : Success` tail.
|
||
- Wired: `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs:60–64` — a private
|
||
idempotent `AddOptionsValidation` does
|
||
`TryAddEnumerable(...Singleton<IValidateOptions<HealthMonitoringOptions>, HealthMonitoringOptionsValidator>())`,
|
||
called from all three `Add*HealthMonitoring`/`AddCentralHealthAggregation` entry 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>();`; `:37` `DefaultCapBytes > 0` (→ `RequireThat`);
|
||
`:44` cross-field `ErrorCapBytes >= DefaultCapBytes` (→ `RequireThat`); `:52` `RetentionDays` ∈
|
||
[30, 3650] (→ `RequireThat`, bounds at `:19–22`); `:59` `InboundMaxBytes` ∈ [8 KiB, 16 MiB]
|
||
(→ `RequireThat`, bounds at `:25–28`).
|
||
- `:66–68` — the `Count == 0 ? Success : Fail` tail.
|
||
- 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 exact
|
||
`AddValidatedOptions` triple, spelled out.
|
||
|
||
> Other modules bind options with no validator (`Communication`, `DataConnectionLayer`,
|
||
> `Transport`, `Notification*`, `ExternalSystemGateway`, `ManagementService`, `SiteCallAudit`,
|
||
> `DeploymentManager` — all `AddOptions().BindConfiguration(...)` without `ValidateOnStart` or a
|
||
> validator). They are candidates for `AddValidatedOptions` only 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 off `configuration` (no binding):
|
||
- `:16` `ScadaBridge:Node:Role` ∈ {`Central`, `Site`} (→ `Require(key, predicate, reason)`);
|
||
- `:20` `Node:NodeHostname` required (→ `RequireValue`);
|
||
- `:23` `Node:RemotingPort` parseable port 1–65535 (→ `RequirePort`);
|
||
- `:27` `Node:SiteId` required **when** role == Site (→ `When(role == "Site", ...)`);
|
||
- `:30–41` `Database:ConfigurationDb` / `Security:LdapServer` / `Security:JwtSigningKey`
|
||
required **when** role == Central (→ `When(role == "Central", ...)`);
|
||
- `:43` `Cluster:SeedNodes` ≥ 2 entries (→ a `Require`/custom rule over the bound list);
|
||
- `:47–79` Site-only rules: `GrpcPort` range (`:49`), `GrpcPort != RemotingPort` (`:58`),
|
||
`Database:SiteDbPath` required (`:61`), and seed-node-must-not-target-gRPC-port (`:69–78`) —
|
||
all under a `When(role == "Site", ...)` block, with `SeedNodePort` (`:90`) as a domain helper
|
||
that stays per-project.
|
||
- `:81–83` — **the throw:**
|
||
```csharp
|
||
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>`, replace
|
||
`Validate(string?, T)` with `protected override void Validate(ValidationBuilder v, T o)`, delete
|
||
the `List<string> failures` and the `Count`-based tail, and re-express each rule on `v`:
|
||
- `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 `ServiceCollectionExtensions` to register via
|
||
`AddValidatedOptions<T, TValidator>(configuration, "<section>")` instead of the
|
||
`AddOptions().Bind/BindConfiguration(...).ValidateOnStart()` + `AddSingleton`/`TryAddEnumerable`
|
||
pair (`ClusterInfrastructure/ServiceCollectionExtensions.cs:28–29`;
|
||
`Security/ServiceCollectionExtensions.cs:28–30`;
|
||
`HealthMonitoring/ServiceCollectionExtensions.cs:60–64`;
|
||
`AuditLog/ServiceCollectionExtensions.cs:65–68`). Where a module uses `TryAddEnumerable` for
|
||
idempotency across multiple entry points (HealthMonitoring), keep an idempotency guard around the
|
||
single `AddValidatedOptions` call.
|
||
|
||
**Migrate `StartupValidator` → `ConfigPreflight`:**
|
||
|
||
- Replace the body of `StartupValidator.Validate(IConfiguration)` with a `ConfigPreflight.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 (`GrpcPort` range, `GrpcPort != RemotingPort`, `SiteDbPath`, seed-vs-gRPC-port),
|
||
then `.ThrowIfInvalid()`. Keep the `SeedNodePort` helper and the seed-node/gRPC-port custom rules
|
||
as `Require(...)` 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 the
|
||
`InvalidOperationException` type. 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`](../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.
|