docs(config): components/configuration normalization (spec, shared-contract, current-state x3, GAPS, README)
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# Configuration validation — current state: MxAccessGateway
|
||||
|
||||
Repo: `~/Desktop/MxAccessGateway` (`mxaccessgw`). Stack: .NET 10 gateway (x64) + .NET 4.8 worker
|
||||
(x86), gRPC; solution `src/MxGateway.sln`. All paths relative to repo root. Verified 2026-06-01.
|
||||
|
||||
MxGateway has **one large, well-structured options validator** for a single composite
|
||||
`GatewayOptions`, wired through a bespoke DI extension. It is the textbook hand-rolled version of
|
||||
exactly what the shared library normalizes: a private `List<string>` accumulator, a stack of
|
||||
`AddIfXxx` helper methods, and an `AddOptions().BindConfiguration().ValidateOnStart()` registration
|
||||
— all of which collapse onto `OptionsValidatorBase` + `AddValidatedOptions` with the domain rules
|
||||
left untouched.
|
||||
|
||||
## 1. `GatewayOptionsValidator` — hand-rolled `IValidateOptions<GatewayOptions>`
|
||||
|
||||
`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs`:
|
||||
- `:6` — `public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>` —
|
||||
implements the interface directly (no shared base).
|
||||
- `:17–34` — `Validate(string? name, GatewayOptions options)`: creates `List<string> failures`
|
||||
(`:19`), dispatches to **nine** sub-validators (`:21–29`), and returns the
|
||||
`failures.Count == 0 ? Success : Fail(failures)` tail (`:31–33`). This is precisely the
|
||||
accumulate-all-then-decide convention the base owns.
|
||||
- Sub-validators (each takes `(section options, List<string> failures)` and `failures.Add(...)`s):
|
||||
- `:36` `ValidateAuthentication` — `Enum.IsDefined` on `Mode`; conditional required
|
||||
`SqlitePath` / `PepperSecretName` when `Mode == ApiKey`.
|
||||
- `:61` `ValidateLdap` — short-circuits when `!Enabled` (`:63`); seven required-string checks
|
||||
(`:68–89`), `Port` positivity (`:90`), and a cross-field `UseTls`/`AllowInsecureLdap` rule (`:92`).
|
||||
- `:98` `ValidateWorker` — required `ExecutablePath` (`:100`), valid-path + `.exe`-extension
|
||||
checks (`:101–110`), `Enum.IsDefined` on architecture (`:120`), eight positive-int checks
|
||||
(`:125–152`), and a cross-field `HeartbeatGraceSeconds >= HeartbeatIntervalSeconds` rule
|
||||
(`:154`), plus a `MaxMessageBytes` range (`:160`).
|
||||
- `:167` `ValidateSessions` — five positive-int checks (`:169–185`) + an "unsupported feature"
|
||||
guard (`:187`).
|
||||
- `:194` `ValidateEvents` — `QueueCapacity` positivity (`:196`) + `Enum.IsDefined` on policy (`:198`).
|
||||
- `:204` `ValidateDashboard` — `GroupToRole` map shape (`:211–224`) + interval/limit bounds (`:226–237`).
|
||||
- `:240` `ValidateAlarms` — short-circuits when `!Enabled` (`:242`); a "need expression or area"
|
||||
rule (`:251`) + a canonical-prefix rule (`:258`).
|
||||
- `:269` `ValidateTls` — `ValidityYears` range (`:271`), required non-blank cert path + valid path
|
||||
(`:278–285`), non-blank DNS-name entries (`:287`).
|
||||
- `:296` `ValidateProtocol` — exact `WorkerProtocolVersion` match (`:298`) + `MaxGrpcMessageBytes`
|
||||
range (`:304`).
|
||||
- **Private helpers that duplicate the shared primitives** (`:311–358`):
|
||||
- `:311` `AddIfBlank` → maps to `ValidationBuilder.Required`.
|
||||
- `:319` `AddIfNotPositive` → maps to `RequireThat(value > 0, ...)`.
|
||||
- `:327` `AddIfNegative` → maps to `RequireThat(value >= 0, ...)`.
|
||||
- `:335` `AddIfInvalidPath` → app-specific; stays as a `RequireThat`/`Add` custom rule
|
||||
(filesystem-path validity is not a shared primitive).
|
||||
|
||||
Every failure message is the gateway's own (`"MxGateway:<Section>:<Field> ..."`) — these are
|
||||
**domain rules and stay per-project**; only the accumulation plumbing and the trivial helpers move
|
||||
to the base.
|
||||
|
||||
## 2. DI wiring — `AddGatewayConfiguration`
|
||||
|
||||
`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs`:
|
||||
- `:10–21` — `AddGatewayConfiguration(this IServiceCollection services)`:
|
||||
- `:12–15` — `services.AddOptions<GatewayOptions>().BindConfiguration(GatewayOptions.SectionName).ValidateOnStart();`
|
||||
- `:17` — `services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();`
|
||||
- `:18` — also registers `IGatewayConfigurationProvider` (a separate concern; stays).
|
||||
|
||||
Lines `:12–17` are exactly the `bind + register-validator + ValidateOnStart` triple that
|
||||
`AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>` collapses into one call. (One nuance:
|
||||
the gateway uses `BindConfiguration(SectionName)` — which reads the section path off the type/const
|
||||
— whereas `AddValidatedOptions` takes an explicit `sectionPath` string; the adoption passes
|
||||
`GatewayOptions.SectionName` as that argument.)
|
||||
|
||||
A bad `MxGateway` section surfaces as **`OptionsValidationException`** at host start, via
|
||||
`ValidateOnStart()` — the same path `AddValidatedOptions` produces.
|
||||
|
||||
## 3. Summary
|
||||
|
||||
| Surface | What exists | Shared-lib mapping |
|
||||
|---|---|---|
|
||||
| Options validator | `GatewayOptionsValidator : IValidateOptions<GatewayOptions>` (~360 LOC, 9 sub-validators) | → `OptionsValidatorBase<GatewayOptions>` |
|
||||
| Failure accumulation | private `List<string> failures` + `Count == 0 ? Success : Fail` tail | → owned by base + `ValidationBuilder` |
|
||||
| Rule helpers | `AddIfBlank` / `AddIfNotPositive` / `AddIfNegative` | → `Required` / `RequireThat` primitives |
|
||||
| App-specific helper | `AddIfInvalidPath` | → stays as a custom `RequireThat`/`Add` rule |
|
||||
| DI wiring | `AddGatewayConfiguration` (`AddOptions().BindConfiguration().ValidateOnStart()` + `AddSingleton<IValidateOptions...>`) | → `AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>` |
|
||||
| Pre-host preflight | none (single host, no pre-Akka stage) | n/a — `ConfigPreflight` not needed |
|
||||
|
||||
---
|
||||
|
||||
## Adoption plan → `ZB.MOM.WW.Configuration`
|
||||
|
||||
**Migrate the validator to the shared base:**
|
||||
|
||||
- Change `GatewayOptionsValidator : IValidateOptions<GatewayOptions>` →
|
||||
`GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>`
|
||||
(`GatewayOptionsValidator.cs:6`).
|
||||
- Replace the public `Validate(string? name, GatewayOptions options)` (`:17`) with the
|
||||
`protected override void Validate(ValidationBuilder v, GatewayOptions options)`. Delete the
|
||||
`List<string> failures` and the `Count == 0 ? Success : Fail` tail (`:19`, `:31–33`) — the base
|
||||
supplies both.
|
||||
- Keep the nine sub-validators but re-thread them to take the `ValidationBuilder` instead of
|
||||
`List<string>`. Map the helpers: `AddIfBlank` → `v.Required(...)`, `AddIfNotPositive(x, msg)` →
|
||||
`v.RequireThat(x > 0, msg)`, `AddIfNegative(x, msg)` → `v.RequireThat(x >= 0, msg)`. Keep
|
||||
`AddIfInvalidPath` as a private helper that records via `v.Add(...)` (filesystem-path validity is
|
||||
app-specific; not a shared primitive). **All gateway message strings are preserved verbatim** —
|
||||
domain rules do not change.
|
||||
|
||||
**Migrate the DI wiring:**
|
||||
|
||||
- In `AddGatewayConfiguration` (`GatewayConfigurationServiceCollectionExtensions.cs:12–17`),
|
||||
replace the `AddOptions().BindConfiguration().ValidateOnStart()` + `AddSingleton<IValidateOptions...>`
|
||||
pair with:
|
||||
```csharp
|
||||
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||
configuration, GatewayOptions.SectionName);
|
||||
```
|
||||
(The extension gains an `IConfiguration` parameter, or resolves it from the builder, since
|
||||
`AddValidatedOptions` binds from an explicit `IConfiguration` rather than the ambient
|
||||
`BindConfiguration`.) The `IGatewayConfigurationProvider` registration (`:18`) is unrelated and
|
||||
stays.
|
||||
|
||||
**Keep bespoke (unchanged):**
|
||||
|
||||
- Every `"MxGateway:<Section>:<Field>"` message and every domain rule (worker `.exe` extension,
|
||||
heartbeat grace ≥ interval, protocol-version exact match, `\\`-prefixed alarm expression, etc.).
|
||||
- `GatewayOptions` and its section types — these are MxGateway's options classes; not shared.
|
||||
- The net48 x86 worker — does no `IConfiguration` validation; excluded entirely.
|
||||
|
||||
**Status:** follow-on (tracked in [`../GAPS.md`](../GAPS.md)). Medium-weight, low-risk —
|
||||
behaviour-preserving plumbing swap; one validator, one DI extension.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Configuration validation — current state: OtOpcUa
|
||||
|
||||
Repo: `~/Desktop/OtOpcUa`. Stack: .NET 10, OPC UA, gRPC; solution `ZB.MOM.WW.OtOpcUa.slnx`.
|
||||
All paths relative to repo root. Verified 2026-06-01.
|
||||
|
||||
**Headline:** OtOpcUa has **no startup options validation at all**. A repo-wide search for
|
||||
`IValidateOptions` and `ValidateOnStart` returns **zero** hits in `src/`. Options are bound with
|
||||
bare `.Bind(...)` and never validated. The only "validation" in the configuration namespace is
|
||||
`DraftValidator` — but that is **runtime draft/snapshot validation of operator config drafts**,
|
||||
not `IConfiguration`/options validation, and it is **out of scope** for the shared library.
|
||||
|
||||
This makes OtOpcUa the **lightest** consumer: there is nothing to *replace*, only an optional
|
||||
opportunity to *add* the missing startup validation using the shared base.
|
||||
|
||||
## 1. Options binding — no validation
|
||||
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`:
|
||||
- `:99` — `builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));`
|
||||
Bound, **not** validated — no `ValidateOnStart()`, no registered `IValidateOptions<LdapOptions>`.
|
||||
A blank `Ldap:Server` / `Ldap:SearchBase` would surface only later, as a low-level LDAP error on
|
||||
the first login (the exact failure mode ScadaBridge's `SecurityOptionsValidator` exists to
|
||||
prevent).
|
||||
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs`:
|
||||
- `:63` — `_configuration.GetSection("OpcUa").Bind(options);` — the `OpcUa` section is bound
|
||||
imperatively inside the hosted service, again with no validation pass.
|
||||
|
||||
There is no `*OptionsValidator` type and no `AddValidatedOptions`-style helper anywhere in `src/`.
|
||||
The repo simply trusts its config sections.
|
||||
|
||||
## 2. `DraftValidator` / `DraftSnapshot` — runtime draft validation (OUT OF SCOPE)
|
||||
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs`:
|
||||
- `:14` — `public static class DraftValidator` — a **managed pre-publish validator** (its own
|
||||
doc-comment, `:7–13`, frames it as the managed-code complement to the T-SQL `sp_ValidateDraft`).
|
||||
- `:24` — `public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)` — runs seven
|
||||
rule groups (`:28–34`): UNS segment regex (`:42`), path length ≤ 200 (`:64`), EquipmentUuid
|
||||
immutability (`:89`), same-cluster namespace binding (`:104`), reservation pre-flight (`:125`),
|
||||
EquipmentId derivation (`:153`), driver/namespace compatibility (`:165`).
|
||||
- `:206` — `public static IReadOnlyList<ValidationError> ValidateClusterTopology(...)` — a second
|
||||
managed guard for cluster topology vs `RedundancyMode`.
|
||||
- It returns **every** failing rule in one pass — same "surface all errors" philosophy this
|
||||
component normalizes — but over **database draft rows** (`DraftSnapshot`), not `IConfiguration`.
|
||||
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs`:
|
||||
- `:9` — `public sealed class DraftSnapshot` — the input bag: namespaces, driver instances,
|
||||
equipment, UNS areas/lines, tags, poll groups, plus prior-generation rows for cross-generation
|
||||
invariants. These are domain entities (`ZB.MOM.WW.OtOpcUa.Configuration.Entities`), not options.
|
||||
|
||||
`DraftValidator` is referenced only by its tests
|
||||
(`tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs`) and the publish
|
||||
pipeline — never from any DI / options registration. It produces `ValidationError`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/ValidationError.cs`), a domain record, not
|
||||
`ValidateOptionsResult`.
|
||||
|
||||
**Why it stays per-project:** it validates an operator's configuration *content* (the equipment
|
||||
hierarchy they are about to publish), with rules that are entirely OtOpcUa domain knowledge (UNS
|
||||
regex, EquipmentId derivation, Galaxy driver/namespace rules). It is not the cross-cutting
|
||||
"validate the host's config section at startup" concern the shared library normalizes. Nothing
|
||||
about it changes on adoption.
|
||||
|
||||
## 3. Summary
|
||||
|
||||
| Surface | What exists | Shared-lib relevance |
|
||||
|---|---|---|
|
||||
| Startup options validation | **None** — `LdapOptions`/`OpcUa` bound with bare `.Bind()` | **Gap** — could adopt `OptionsValidatorBase` + `AddValidatedOptions` |
|
||||
| `IValidateOptions` / `ValidateOnStart` | **Zero usages in `src/`** | nothing to migrate |
|
||||
| Pre-host raw-config preflight | **None** | could adopt `ConfigPreflight` if pre-host keys emerge |
|
||||
| Runtime draft validation | `DraftValidator` + `DraftSnapshot` (one-pass, all errors) | **out of scope** — stays per-project |
|
||||
|
||||
---
|
||||
|
||||
## Adoption plan → `ZB.MOM.WW.Configuration`
|
||||
|
||||
OtOpcUa is the lightest consumer — adoption is **additive**, not a replacement, and is entirely
|
||||
optional (no existing validation is wrong, there just isn't any).
|
||||
|
||||
**Add startup validation for the bound sections (optional, recommended):**
|
||||
|
||||
- For `Ldap`: add an `LdapStartupOptionsValidator : OptionsValidatorBase<LdapOptions>` that calls
|
||||
`v.Required(o.Server, "Ldap:Server")` and `v.Required(o.SearchBase, "Ldap:SearchBase")`
|
||||
(mirroring ScadaBridge's `SecurityOptionsValidator` intent), then replace
|
||||
`Program.cs:99`'s `AddOptions<LdapOptions>().Bind(...)` with
|
||||
`AddValidatedOptions<LdapOptions, LdapStartupOptionsValidator>(builder.Configuration, "Ldap")`.
|
||||
- For `OpcUa`: if any field has a fail-fast invariant (e.g. a required endpoint or a port), add an
|
||||
`OptionsValidatorBase<OpcUaOptions>` and move the `:63` imperative `.Bind` into
|
||||
`AddValidatedOptions` at composition time. Skip if the section has no hard invariants.
|
||||
|
||||
**Keep bespoke (unchanged):**
|
||||
|
||||
- `DraftValidator` and `DraftSnapshot` — **out of scope**. Runtime draft/snapshot validation,
|
||||
domain rules, `ValidationError` output, publish-pipeline call site — all stay exactly as they
|
||||
are. Do **not** fold them into `OptionsValidatorBase`; they are not options validation.
|
||||
|
||||
**Status:** OtOpcUa has no validator to migrate today, so its adoption is purely the *new*
|
||||
guarding work above. It is a **follow-on** (tracked in [`../GAPS.md`](../GAPS.md)), low priority —
|
||||
the lowest-stakes of the three because there is no drift to correct, only an absence to optionally
|
||||
fill once the package is referenced.
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user