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

11 KiB
Raw Blame History

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: "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.

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.

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).

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 pack1 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 StartupValidatorConfigPreflight 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)

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.