Files
scadaproj/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md
T
Joseph Doherty 18e4b70572 docs(plans): design ZB.MOM.WW.Configuration shared startup-options-validation library
Approved brainstorming design for the Config + validation normalization pass
(Tier-2 candidate in upcoming.md). Scope: startup options validation only,
single package ZB.MOM.WW.Configuration, Approach A (lightweight base + rule
primitives + DI/startup helpers). Full pass = components/configuration/ docs +
built library.
2026-06-01 09:10:35 -04:00

212 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ZB.MOM.WW.Configuration — shared startup-options-validation library (design)
**Date:** 2026-06-01
**Status:** Design (approved) — implementation plan to follow via writing-plans
**Component:** Config + validation (Tier-2 normalization candidate, `upcoming.md`)
**Deliverable:** Full normalization pass — `components/configuration/` docs **and** a built
`ZB.MOM.WW.Configuration` library + this design doc.
## 1. Problem & intent
The three sister apps (OtOpcUa, MxAccessGateway, ScadaBridge) each re-implement the same
configuration-validation plumbing and drift apart. All three use the same .NET primitives —
`IOptions<T>` + `IValidateOptions<T>` + `ValidateOnStart()` — and each hand-rolls a
`List<string>` failure accumulator that returns `ValidateOptionsResult`. ScadaBridge also has a
pre-host `StartupValidator` that validates raw `IConfiguration` *before* the Akka actor system
exists (so it cannot use `IOptions`), aggregates all errors, and throws one
`InvalidOperationException`.
This was flagged as a Tier-2 normalization candidate in
[`upcoming.md`](../../upcoming.md): *"all three use IOptions + `IValidateOptions` +
`ValidateOnStart`; a shared validation base + startup-validation helper is reusable."*
**Goal:** extract the genuinely-common plumbing into one shared library, removing the
duplicated boilerplate while leaving every app's domain-specific rules per-project — per the
`components/README.md` rule: *"extract only what is genuinely common; leave domain-specific
logic in the projects."*
### Decisions locked during brainstorming
| Decision | Choice | Rationale |
|---|---|---|
| Deliverable | **Full pass** (normalization docs + built library) | Match Auth/Theme/Health/Telemetry exactly |
| Scope | **Startup options validation only** | Genuinely common across all 3; honors YAGNI |
| Package name | **`ZB.MOM.WW.Configuration`** | Full-word style; aligns with `Microsoft.Extensions.Configuration`/`Options` |
| API style | **Approach A** — lightweight base + rule primitives + DI/startup helpers | Mirrors what all 3 already do; no new fleet-wide dependency; single package |
### Explicitly out of scope (YAGNI)
- **Runtime draft/snapshot validation** — OtOpcUa's `DraftValidator`/`DraftSnapshot`
(validating admin-UI-submitted config drafts before commit) stays per-project; only ~1 of 3
apps uses it.
- **A FluentValidation or DataAnnotations dependency** — the apps hand-roll `IValidateOptions`
*because* their rules are conditional/cross-field, which DataAnnotations can't express and
FluentValidation only solves by adding a new fleet-wide dependency.
- **Editing the three apps' code** — adoption is a tracked follow-on in `GAPS.md`, not part of
this pass (same stance as Auth/Health/Theme).
## 2. Approaches considered
- **A — Lightweight base + rule primitives + DI/startup helpers (chosen).** Extract exactly
the boilerplate the apps repeat; keep domain rules per-project. Minimal surface, idiomatic,
stays inside .NET's native options pipeline, no new external dependency → single package.
- **B — Fluent rule-builder DSL** (`RuleFor(o => o.Port).InRange(...)`). Nicer ergonomics but a
much larger surface to build/test/maintain, and property-chains express the apps' real
role-conditional/cross-field rules awkwardly. Rejected: over-built.
- **C — Wrap DataAnnotations or FluentValidation.** Less code to own, but DataAnnotations is too
weak for the apps' conditional rules (the reason they hand-roll today) and FluentValidation is
a new fleet-wide dependency for modest gain. Rejected.
## 3. Architecture & repository layout
One new .NET 10 package in its own nested git repo at the workspace root, plus the
normalization paper trail and this design doc:
```
scadaproj/
components/configuration/
README.md
spec/SPEC.md # the one normalized target
shared-contract/ZB.MOM.WW.Configuration.md # proposed public API
current-state/{otopcua,mxaccessgw,scadabridge}/CURRENT-STATE.md
GAPS.md # divergence + adoption backlog
ZB.MOM.WW.Configuration/ # the built library (nested git repo)
src/ZB.MOM.WW.Configuration/
tests/ZB.MOM.WW.Configuration.Tests/
ZB.MOM.WW.Configuration.slnx
docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md # this file
```
**Dependencies (single package — no heavy dep to isolate):**
`Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions`,
`Microsoft.Extensions.Configuration.Abstractions`,
`Microsoft.Extensions.DependencyInjection.Abstractions` — all already referenced by all three
apps. This is why a single package is the right shape, unlike the multi-package splits Auth
(LDAP/crypto/ASP.NET) and Health (Akka/EF) needed.
## 4. Public API surface
Four public types in namespace `ZB.MOM.WW.Configuration`; everything else internal.
### 4.1 `OptionsValidatorBase<TOptions>` (abstract `IValidateOptions<TOptions>`)
Removes the `List<string>``ValidateOptionsResult` plumbing every validator repeats. The base
implements `Validate(name, options)`, hands a fresh `ValidationBuilder` to one overridden method,
and returns `Success` only when no failures were accumulated — otherwise `Fail` with **all**
messages.
```csharp
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
{
protected override void Validate(ValidationBuilder v, ClusterOptions o)
{
v.MinCount(o.SeedNodes, 2, "ClusterOptions.SeedNodes");
v.OneOf(o.SplitBrainResolverStrategy, AllowedStrategies, "ClusterOptions.SplitBrainResolverStrategy");
v.RequireThat(o.MinNrOfMembers == 1, "ClusterOptions.MinNrOfMembers must be 1");
v.PositiveTimeSpan(o.StableAfter, "ClusterOptions.StableAfter");
}
}
```
### 4.2 `ValidationBuilder` (failure accumulator)
Carries the standard rule primitives (each *checks and* appends a consistently-formatted
message) plus a low-level escape hatch for custom/cross-field rules.
- **Primitives:** `Required(string?, field)`, `Port(int, field)`, `HostPort(string?, field)`,
`PositiveTimeSpan(TimeSpan, field)`, `OneOf(value, allowed, field)`,
`MinCount(collection, n, field)`.
- **Escape hatch:** `RequireThat(bool ok, string message)` and `Add(string message)` — for the
role-conditional / cross-field rules the apps genuinely need (e.g. "SiteId required only when
Role=Site").
### 4.3 `ServiceCollectionExtensions.AddValidatedOptions<TOptions, TValidator>(config, sectionPath)`
One call = bind section + register the validator + `ValidateOnStart()`. Returns the
`OptionsBuilder<TOptions>` so callers can chain `.PostConfigure(...)`. Replaces each module's
bespoke `AddXxx()` wiring.
```csharp
services.AddValidatedOptions<ClusterOptions, ClusterOptionsValidator>(config, "ScadaBridge:Cluster");
```
### 4.4 `ConfigPreflight` (fluent aggregator over raw `IConfiguration`)
For checks that must run *before* the host/DI exists (generalizes ScadaBridge's pre-Akka
`StartupValidator`).
```csharp
ConfigPreflight.For(configuration)
.Require("ScadaBridge:Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
.RequirePort("ScadaBridge:Node:RemotingPort")
.When(role == "Site", p => p.Require("ScadaBridge:Node:SiteId", NotEmpty, "required for Site nodes"))
.ThrowIfInvalid(); // throws one InvalidOperationException listing ALL errors
```
**DRY:** `ValidationBuilder` and `ConfigPreflight` share one internal `Checks` helper so rule
logic and message wording are identical whether validating a bound options object or raw config
keys.
## 5. Error-handling contract
- **Accumulate, never fail-fast-on-first.** Both front-ends collect *all* failures and surface
them together — one boot shows every config problem, not just the first.
- **Two surfacing paths:** options validators flow through `ValidateOnStart()` → host throws
`OptionsValidationException` at startup; `ConfigPreflight.ThrowIfInvalid()` throws a single
`InvalidOperationException` with a newline-bulleted list — **byte-compatible with
ScadaBridge's current `StartupValidator` message shape**, so its swap is a drop-in.
- **Consistent message format:** `"<Section:Key> <reason>"`, one per failure.
## 6. Testing
xUnit, matching the sibling repos; target ~4060 tests (cf. Health 58 / Telemetry 19).
- Each primitive at its boundaries — `Port` at 0/1/65535/65536, `Required` null/empty/whitespace,
`HostPort` parse success/failure, `PositiveTimeSpan` zero/negative, `OneOf` case-insensitivity,
`MinCount`.
- Base aggregation: multiple failures all reported; clean options → `Success`.
- `AddValidatedOptions`: bad config makes the host throw at start; good config resolves;
`ValidateOnStart` actually fires.
- `ConfigPreflight`: aggregation, `When(...)` conditional branches, `ThrowIfInvalid` message
format.
## 7. Versioning, packaging & adoption
- **Version `0.1.0`**, `dotnet pack`**1 nupkg**. Build/test from `ZB.MOM.WW.Configuration/`
via `dotnet test` — identical conventions to the other four libraries.
- **Not adopted in-pass** (same stance as Auth/Health/Theme). The `current-state/` docs map each
app's validators with `file:line` refs, and `GAPS.md` becomes the per-project
delete/replace backlog.
- **Consumer matrix:** all three apps consume the single package. ScadaBridge is the heaviest
adopter (most `*OptionsValidator` + the `StartupValidator``ConfigPreflight` swap); OtOpcUa
and MxGateway adopt the base + `AddValidatedOptions` for their options validators.
### Current-state anchors (to verify against live code during the pass)
- **OtOpcUa:** `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/{DraftValidator,DraftSnapshot}.cs`
(draft validation stays per-project); options validators elsewhere in `…Configuration`.
- **MxAccessGateway:** `src/ZB.MOM.WW.MxGateway.Server/Configuration/{GatewayOptionsValidator,
GatewayConfigurationServiceCollectionExtensions}.cs`.
- **ScadaBridge:** per-module `*OptionsValidator.cs` (`ClusterInfrastructure`, `Security`,
`HealthMonitoring`, `AuditLog`) + `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs` +
each module's `ServiceCollectionExtensions.cs`.
## 8. Index/registry housekeeping (part of this pass)
- New row in [`components/README.md`](../../components/README.md) registry.
- Component-normalization table + a normalization paragraph in the root
[`CLAUDE.md`](../../CLAUDE.md).
- Tick the "Config + validation" Tier-2 item in [`upcoming.md`](../../upcoming.md).
## 9. Implementation task outline
Tracked in the session task list; dependency order:
1. **Write `components/configuration/` normalization docs** (spec, shared-contract, 3×
current-state with `file:line`, GAPS, README).
2. **Implement `ZB.MOM.WW.Configuration`** (the four public types + internal `Checks`).
3. **Add `ZB.MOM.WW.Configuration.Tests`** (~4060 xUnit tests).
4. **Update registry/index + package** (`dotnet pack` → 1 nupkg @ 0.1.0; init nested git repo).
A detailed, bite-sized implementation plan follows via the writing-plans skill.