Merge feat/zb-mom-ww-configuration: Configuration normalization component + ZB.MOM.WW.Configuration (0.1.0)
Shared startup-options-validation library (single package, 27 tests) — OptionsValidatorBase, ValidationBuilder primitives, AddValidatedOptions (ValidateOnStart), and pre-host ConfigPreflight (byte-compatible with ScadaBridge's StartupValidator). Plus components/configuration normalization docs (spec, shared-contract, 3x current-state, GAPS) and index registration. Not yet adopted by the three apps — adoption tracked in components/configuration/GAPS.md.
This commit is contained in:
@@ -6,11 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
`scadaproj` is primarily an umbrella/index workspace that aggregates a family of
|
||||
related SCADA / OT / Wonderware / OPC UA "sister projects" that live as **sibling
|
||||
directories under `~/Desktop/`**. It now also **hosts four pieces of source itself** —
|
||||
directories under `~/Desktop/`**. It now also **hosts five pieces of source itself** —
|
||||
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library, the shared
|
||||
[`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) UI kit, the shared
|
||||
[`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) health-check library, and the shared
|
||||
[`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) observability library — all the realized output of their
|
||||
[`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) health-check library, the shared
|
||||
[`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) observability library, and the shared
|
||||
[`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) config-validation library — all the realized output of their
|
||||
respective component normalizations (see [Component normalization](#component-normalization)).
|
||||
The point of this file is to give a high-level scan of each sister project — its purpose,
|
||||
location, stack, and primary commands — so a fresh Claude Code session can orient across
|
||||
@@ -123,6 +124,7 @@ each project's **code-verified current state**, and the **gaps** between. See
|
||||
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
||||
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
|
||||
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
|
||||
| Config + validation (options / startup validation) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
|
||||
| Audit (event model + writer seam) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||
|
||||
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
||||
@@ -187,6 +189,25 @@ follow-on, tracked in [`components/observability/GAPS.md`](components/observabil
|
||||
Build/test from `ZB.MOM.WW.Telemetry/`: `dotnet test`. Consumer matrix: all three apps consume both
|
||||
packages after adoption (OtOpcUa, MxGateway Server, ScadaBridge Host + any instrumented project).
|
||||
|
||||
The configuration component is fully populated: a normalized [`spec`](components/configuration/spec/SPEC.md), a
|
||||
[`shared-contract`](components/configuration/shared-contract/ZB.MOM.WW.Configuration.md), three
|
||||
[`current-state`](components/configuration/current-state/) docs, and an adoption [`GAPS`](components/configuration/GAPS.md)
|
||||
backlog. Shared = the `IValidateOptions<T>` failure-accumulation base (`OptionsValidatorBase<T>`) +
|
||||
reusable rule primitives (`ValidationBuilder`: port / host:port / required / positive-duration / one-of /
|
||||
min-count) + `AddValidatedOptions<TOptions,TValidator>()` (bind + validate + `ValidateOnStart`) + the
|
||||
pre-host `ConfigPreflight` aggregator (generalizes ScadaBridge's `StartupValidator`, byte-compatible
|
||||
message); left per-project = each app's options classes + domain rules, and OtOpcUa's runtime
|
||||
draft/snapshot validation.
|
||||
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/)
|
||||
(.NET 10; single package `ZB.MOM.WW.Configuration`; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0).
|
||||
The implementation plan is at
|
||||
[`docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md).
|
||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/configuration/GAPS.md`](components/configuration/GAPS.md).
|
||||
Build/test from `ZB.MOM.WW.Configuration/`: `dotnet test`. Consumer matrix: all three apps consume the
|
||||
single package; ScadaBridge is the heaviest adopter (per-module validators + `StartupValidator` →
|
||||
`ConfigPreflight`); OtOpcUa adoption is additive (it has no `IValidateOptions` usage today).
|
||||
|
||||
The audit component is fully populated: a normalized [`spec`](components/audit/spec/SPEC.md), an
|
||||
[`event-model`](components/audit/spec/EVENT-MODEL.md) reference, a
|
||||
[`shared-contract`](components/audit/shared-contract/ZB.MOM.WW.Audit.md), three
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Tye
|
||||
.tye/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
.idea/
|
||||
|
||||
##
|
||||
## Visual studio for Mac
|
||||
##
|
||||
|
||||
|
||||
# globs
|
||||
Makefile.in
|
||||
*.userprefs
|
||||
*.usertasks
|
||||
config.make
|
||||
config.status
|
||||
aclocal.m4
|
||||
install-sh
|
||||
autom4te.cache/
|
||||
*.tar.gz
|
||||
tarballs/
|
||||
test-results/
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
@@ -0,0 +1,77 @@
|
||||
# ZB.MOM.WW.Configuration
|
||||
|
||||
Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — the package is linked directly into the consuming application at build time. There is no central validation process; all validation runs in-process at startup.
|
||||
|
||||
The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project.
|
||||
|
||||
**Built at 0.1.0. Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge.** Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Package
|
||||
|
||||
| Package | Responsibilities | Key Dependencies |
|
||||
|---|---|---|
|
||||
| `ZB.MOM.WW.Configuration` | `OptionsValidatorBase<TOptions>` (abstract `IValidateOptions` base, failure-accumulating), `ValidationBuilder` (rule primitives: `Required`, `Port`, `HostPort`, `PositiveTimeSpan`, `OneOf`, `MinCount`, `RequireThat`, `Add`), `ServiceCollectionExtensions.AddValidatedOptions` (bind + validator + `ValidateOnStart` in one call), `ConfigPreflight` (fluent pre-host raw-`IConfiguration` checker). | `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions`, `Microsoft.Extensions.Configuration.Abstractions`, `Microsoft.Extensions.DependencyInjection.Abstractions` |
|
||||
|
||||
Single package; no ASP.NET Core framework reference.
|
||||
|
||||
---
|
||||
|
||||
## Build, test, and pack commands
|
||||
|
||||
```bash
|
||||
# From ZB.MOM.WW.Configuration/
|
||||
|
||||
# Build
|
||||
dotnet build ZB.MOM.WW.Configuration.slnx
|
||||
|
||||
# Test (no external dependencies required)
|
||||
dotnet test ZB.MOM.WW.Configuration.slnx
|
||||
|
||||
# Pack (one .nupkg lands in artifacts/)
|
||||
dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts
|
||||
```
|
||||
|
||||
Test breakdown:
|
||||
|
||||
| Assembly | Tests |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.Configuration.Tests` | 27 |
|
||||
| **Total** | **27** |
|
||||
|
||||
`GeneratePackageOnBuild` is off — pack explicitly with the command above.
|
||||
|
||||
---
|
||||
|
||||
## Source layout
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Configuration/
|
||||
├── Directory.Build.props # version (0.1.0), TFM (net10.0), central package mgmt
|
||||
├── Directory.Packages.props # pinned package versions
|
||||
├── ZB.MOM.WW.Configuration.slnx # solution file
|
||||
├── src/
|
||||
│ └── ZB.MOM.WW.Configuration/ # library project
|
||||
│ ├── OptionsValidatorBase.cs
|
||||
│ ├── ValidationBuilder.cs
|
||||
│ ├── Checks.cs # internal shared rule wording
|
||||
│ ├── ServiceCollectionExtensions.cs
|
||||
│ └── ConfigPreflight.cs
|
||||
└── tests/
|
||||
└── ZB.MOM.WW.Configuration.Tests/ # xUnit test project (27 tests)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge** — follow-on adoption is tracked in:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
||||
|
||||
Design documentation:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/spec/SPEC.md` — normalized validation target
|
||||
- `~/Desktop/scadaproj/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md` — proposed shared-library API
|
||||
- `~/Desktop/scadaproj/components/configuration/current-state/` — per-project current state (code-verified)
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Library -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<!-- Test only -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,112 @@
|
||||
# ZB.MOM.WW.Configuration
|
||||
|
||||
Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). This is a **library, not a service** — the package is linked directly into the consuming application at build time. It extracts the `IValidateOptions` plumbing the three apps share — failure accumulation, rule primitives, bind+validate DI wiring, and pre-host preflight — so that domain-specific validation rules stay per-project and the boilerplate does not drift.
|
||||
|
||||
---
|
||||
|
||||
## What's in the box
|
||||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `OptionsValidatorBase<TOptions>` | Abstract `IValidateOptions<TOptions>`. Override `protected void Validate(ValidationBuilder v, TOptions o)` to declare failures; the base aggregates all failures and returns a single `ValidateOptionsResult`. |
|
||||
| `ValidationBuilder` | Failure accumulator. Primitives: `Required`, `Port`, `HostPort`, `PositiveTimeSpan`, `OneOf`, `MinCount`, `RequireThat(bool, msg)`, `Add(msg)`. Properties: `Failures` (read), `IsValid`. |
|
||||
| `ServiceCollectionExtensions` | `AddValidatedOptions<TOptions, TValidator>(IConfiguration config, string sectionPath)` — binds the section, registers the validator, and calls `ValidateOnStart()` in a single extension method. Returns `OptionsBuilder<TOptions>`. |
|
||||
| `ConfigPreflight` | Pre-host raw-`IConfiguration` checker. Fluent API: `For(config)`, `.Require(key, predicate, reason)`, `.RequireValue(key)`, `.RequirePort(key)`, `.When(cond, block)`, `.ThrowIfInvalid()`. |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Validator subclass
|
||||
|
||||
```csharp
|
||||
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
|
||||
{
|
||||
protected override void Validate(ValidationBuilder v, ClusterOptions o)
|
||||
{
|
||||
v.MinCount(o.SeedNodes, 2, "Cluster:SeedNodes");
|
||||
v.OneOf(o.Strategy, new[] { "keep-oldest" }, "Cluster:Strategy");
|
||||
v.PositiveTimeSpan(o.StableAfter, "Cluster:StableAfter");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. DI wiring
|
||||
|
||||
```csharp
|
||||
builder.Services.AddValidatedOptions<ClusterOptions, ClusterOptionsValidator>(
|
||||
builder.Configuration, "ScadaBridge:Cluster");
|
||||
```
|
||||
|
||||
This binds `ScadaBridge:Cluster`, registers `ClusterOptionsValidator`, and enables `ValidateOnStart` — the app refuses to start if the section fails validation.
|
||||
|
||||
### 3. Pre-host preflight
|
||||
|
||||
```csharp
|
||||
ConfigPreflight.For(configuration)
|
||||
.Require("Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
|
||||
.RequirePort("Node:RemotingPort")
|
||||
.When(role == "Site", p => p.RequireValue("Node:SiteId"))
|
||||
.ThrowIfInvalid();
|
||||
```
|
||||
|
||||
Use `ConfigPreflight` before `WebApplication.CreateBuilder` for critical keys (node role, remoting port, site ID) that must be present and valid before the DI container is even constructed.
|
||||
|
||||
---
|
||||
|
||||
## Building and testing
|
||||
|
||||
```bash
|
||||
# from ZB.MOM.WW.Configuration/
|
||||
dotnet test ZB.MOM.WW.Configuration.slnx
|
||||
```
|
||||
|
||||
All tests run with no external dependencies:
|
||||
|
||||
| Assembly | Tests |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.Configuration.Tests` | 27 |
|
||||
| **Total** | **27** |
|
||||
|
||||
---
|
||||
|
||||
## Packing
|
||||
|
||||
```bash
|
||||
dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts
|
||||
```
|
||||
|
||||
Produces one `.nupkg` file in `artifacts/`:
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Configuration.0.1.0.nupkg
|
||||
```
|
||||
|
||||
`GeneratePackageOnBuild` is off — pack explicitly as above. Version is set in `Directory.Build.props`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
The package has a minimal closure — only `Microsoft.Extensions.*` abstractions:
|
||||
|
||||
- `Microsoft.Extensions.Options`
|
||||
- `Microsoft.Extensions.Options.ConfigurationExtensions`
|
||||
- `Microsoft.Extensions.Configuration.Abstractions`
|
||||
- `Microsoft.Extensions.DependencyInjection.Abstractions`
|
||||
|
||||
No third-party packages; no ASP.NET Core framework reference.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
**Built at 0.1.0. Not yet adopted by the three apps.** Adoption is tracked in the component backlog:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
||||
|
||||
Design documentation lives alongside that backlog:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/spec/SPEC.md` — normalized validation target
|
||||
- `~/Desktop/scadaproj/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md` — proposed API
|
||||
- `~/Desktop/scadaproj/components/configuration/current-state/` — per-project current state (code-verified)
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Internal rule primitives shared by <see cref="ValidationBuilder"/> (validates a bound options
|
||||
/// object) and <see cref="ConfigPreflight"/> (validates raw <c>IConfiguration</c>). Each method
|
||||
/// returns <c>null</c> when valid, or a formatted <c>"<field> <reason>"</c> message
|
||||
/// otherwise. Centralizing them keeps wording identical across both front-ends.
|
||||
/// </summary>
|
||||
internal static class Checks
|
||||
{
|
||||
internal static string? Required(string? value, string field) =>
|
||||
string.IsNullOrWhiteSpace(value) ? $"{field} is required" : null;
|
||||
|
||||
internal static string? Port(int value, string field) =>
|
||||
value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a raw string as a TCP port (parse + range), returning <c>null</c> when valid.
|
||||
/// Centralizes the port wording for callers that hold the raw config value.
|
||||
/// </summary>
|
||||
internal static string? PortValue(string? raw, string field) =>
|
||||
int.TryParse(raw, out var port)
|
||||
? Port(port, field)
|
||||
: $"{field} must be between 1 and 65535 (was '{raw ?? "null"}')";
|
||||
|
||||
/// <summary>
|
||||
/// Validates a non-bracketed <c>host:port</c> endpoint (port 1-65535). Bracketed IPv6
|
||||
/// literals (<c>[::1]:port</c>) are out of scope and are rejected.
|
||||
/// </summary>
|
||||
internal static string? HostPort(string? value, string field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return $"{field} is required";
|
||||
var idx = value.LastIndexOf(':');
|
||||
if (idx <= 0 || idx == value.Length - 1
|
||||
|| value.AsSpan(0, idx).Contains(':')
|
||||
|| !int.TryParse(value[(idx + 1)..], out var port)
|
||||
|| port is < 1 or > 65535)
|
||||
return $"{field} must be 'host:port' with port 1-65535 (was '{value}')";
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string? PositiveTimeSpan(TimeSpan value, string field) =>
|
||||
value <= TimeSpan.Zero ? $"{field} must be a positive duration (was {value})" : null;
|
||||
|
||||
internal static string? OneOf(string? value, IReadOnlyCollection<string> allowed, string field) =>
|
||||
value is not null && allowed.Contains(value, StringComparer.OrdinalIgnoreCase)
|
||||
? null
|
||||
: $"{field} must be one of [{string.Join(", ", allowed)}] (was '{value ?? "null"}')";
|
||||
|
||||
internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) =>
|
||||
value is null || value.Count < min
|
||||
? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})"
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent aggregator for validating raw <see cref="IConfiguration"/> BEFORE the host/DI container
|
||||
/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via
|
||||
/// <see cref="ThrowIfInvalid"/>. For options that flow through DI, prefer
|
||||
/// <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions, TValidator}"/>.
|
||||
/// </summary>
|
||||
public sealed class ConfigPreflight
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly List<string> _failures = [];
|
||||
|
||||
private ConfigPreflight(IConfiguration configuration) => _configuration = configuration;
|
||||
|
||||
/// <summary>Starts a preflight over <paramref name="configuration"/>.</summary>
|
||||
public static ConfigPreflight For(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
return new ConfigPreflight(configuration);
|
||||
}
|
||||
|
||||
/// <summary>The accumulated failure messages (empty when valid).</summary>
|
||||
public IReadOnlyList<string> Failures => _failures;
|
||||
|
||||
/// <summary>True when no failures have been accumulated.</summary>
|
||||
public bool IsValid => _failures.Count == 0;
|
||||
|
||||
/// <summary>Requires the value at <paramref name="key"/> to satisfy <paramref name="predicate"/>.</summary>
|
||||
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Requires a non-empty value at <paramref name="key"/>.</summary>
|
||||
public ConfigPreflight RequireValue(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
return AddIf(Checks.Required(_configuration[key], key));
|
||||
}
|
||||
|
||||
/// <summary>Requires a valid integer TCP port (1-65535) at <paramref name="key"/>.</summary>
|
||||
public ConfigPreflight RequirePort(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
return AddIf(Checks.PortValue(_configuration[key], key));
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="block"/> only when <paramref name="condition"/> holds (role-conditional rules).</summary>
|
||||
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(block);
|
||||
if (condition) block(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Throws <see cref="InvalidOperationException"/> listing all failures when invalid; otherwise returns.</summary>
|
||||
public void ThrowIfInvalid()
|
||||
{
|
||||
if (_failures.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Configuration validation failed:\n{string.Join("\n", _failures.Select(e => $" - {e}"))}");
|
||||
}
|
||||
|
||||
private ConfigPreflight AddIf(string? message)
|
||||
{
|
||||
if (message is not null) _failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for <see cref="IValidateOptions{TOptions}"/> implementations that removes the
|
||||
/// failure-accumulation plumbing. Override <see cref="Validate(ValidationBuilder, TOptions)"/> and
|
||||
/// use the supplied <see cref="ValidationBuilder"/>; the base aggregates ALL failures and returns
|
||||
/// <see cref="ValidateOptionsResult.Success"/> only when none were recorded.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type being validated.</typeparam>
|
||||
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, TOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var builder = new ValidationBuilder();
|
||||
Validate(builder, options);
|
||||
return builder.IsValid
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(builder.Failures);
|
||||
}
|
||||
|
||||
/// <summary>Records validation failures for <paramref name="options"/> on <paramref name="builder"/>.</summary>
|
||||
/// <param name="builder">The accumulator to record failures on.</param>
|
||||
/// <param name="options">The options instance to validate.</param>
|
||||
protected abstract void Validate(ValidationBuilder builder, TOptions options);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>DI extensions for binding-and-validating an options section in one call.</summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Binds <typeparamref name="TOptions"/> to the configuration section at
|
||||
/// <paramref name="sectionPath"/>, registers <typeparamref name="TValidator"/> as its
|
||||
/// <see cref="IValidateOptions{TOptions}"/>, and enables <c>ValidateOnStart</c> so a bad
|
||||
/// configuration fails fast at host startup rather than on first use.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to bind and validate.</typeparam>
|
||||
/// <typeparam name="TValidator">The validator registered for <typeparamref name="TOptions"/>.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration to bind from.</param>
|
||||
/// <param name="sectionPath">The configuration section path (e.g. <c>"ScadaBridge:Cluster"</c>).</param>
|
||||
/// <returns>The <see cref="OptionsBuilder{TOptions}"/> for further chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <typeparamref name="TValidator"/> is registered as a singleton (it is consumed by the
|
||||
/// singleton options factory). It must therefore be safe to use as a singleton — do not
|
||||
/// inject scoped dependencies into it.
|
||||
/// </remarks>
|
||||
public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
|
||||
this IServiceCollection services, IConfiguration configuration, string sectionPath)
|
||||
where TOptions : class
|
||||
where TValidator : class, IValidateOptions<TOptions>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.AddSingleton<IValidateOptions<TOptions>, TValidator>();
|
||||
return services.AddOptions<TOptions>()
|
||||
.Bind(configuration.GetSection(sectionPath))
|
||||
.ValidateOnStart();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates validation failures for an options object. Passed by
|
||||
/// <see cref="OptionsValidatorBase{TOptions}"/> into your <c>Validate</c> override; each primitive
|
||||
/// both checks a value and appends a consistently-formatted message on failure. Use
|
||||
/// <see cref="RequireThat"/>/<see cref="Add"/> for custom or cross-field rules.
|
||||
/// </summary>
|
||||
public sealed class ValidationBuilder
|
||||
{
|
||||
private readonly List<string> _failures = [];
|
||||
|
||||
/// <summary>The accumulated failure messages (empty when validation passed).</summary>
|
||||
public IReadOnlyList<string> Failures => _failures;
|
||||
|
||||
/// <summary>True when no failures have been accumulated.</summary>
|
||||
public bool IsValid => _failures.Count == 0;
|
||||
|
||||
/// <summary>Records <paramref name="message"/> as a failure when <paramref name="ok"/> is false.</summary>
|
||||
public ValidationBuilder RequireThat(bool ok, string message)
|
||||
{
|
||||
if (!ok) _failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Unconditionally records <paramref name="message"/> as a failure.</summary>
|
||||
public ValidationBuilder Add(string message)
|
||||
{
|
||||
_failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Requires a non-null, non-whitespace string.</summary>
|
||||
public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field));
|
||||
|
||||
/// <summary>Requires a TCP port in 1-65535.</summary>
|
||||
public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field));
|
||||
|
||||
/// <summary>Requires a 'host:port' endpoint with a valid port.</summary>
|
||||
public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field));
|
||||
|
||||
/// <summary>Requires a strictly positive duration.</summary>
|
||||
public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field));
|
||||
|
||||
/// <summary>
|
||||
/// Requires the value to be one of <paramref name="allowed"/> (case-insensitive). A
|
||||
/// <c>null</c> value fails this rule; call <see cref="Required"/> first if the field may be
|
||||
/// absent and you want a "required" message instead of a "must be one of" message.
|
||||
/// </summary>
|
||||
public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field) => AddIf(Checks.OneOf(value, allowed, field));
|
||||
|
||||
/// <summary>Requires a collection with at least <paramref name="min"/> items.</summary>
|
||||
public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) => AddIf(Checks.MinCount(value, min, field));
|
||||
|
||||
private ValidationBuilder AddIf(string? message)
|
||||
{
|
||||
if (message is not null) _failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Configuration</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Startup configuration-validation toolkit for the ZB.MOM.WW SCADA family: a failure-accumulating IValidateOptions base, reusable rule primitives (port, host:port, required, positive-duration, one-of, min-count), a bind+validate+ValidateOnStart DI helper, and a pre-host ConfigPreflight aggregator for raw IConfiguration. Extracts the validation plumbing the apps share; domain rules stay per-project.</Description>
|
||||
<PackageTags>configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww</PackageTags>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class AddValidatedOptionsTests
|
||||
{
|
||||
private sealed class NodeOptions { public int Port { get; set; } public string? Name { get; set; } }
|
||||
|
||||
private sealed class NodeValidator : OptionsValidatorBase<NodeOptions>
|
||||
{
|
||||
protected override void Validate(ValidationBuilder v, NodeOptions o)
|
||||
{
|
||||
v.Port(o.Port, "Node:Port");
|
||||
v.Required(o.Name, "Node:Name");
|
||||
}
|
||||
}
|
||||
|
||||
private static IHost BuildHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddValidatedOptions<NodeOptions, NodeValidator>(builder.Configuration, "Node");
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bad_config_throws_at_startup()
|
||||
{
|
||||
using var host = BuildHost(new() { ["Node:Port"] = "0", ["Node:Name"] = "" });
|
||||
await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Good_config_starts_and_binds()
|
||||
{
|
||||
using var host = BuildHost(new() { ["Node:Port"] = "8080", ["Node:Name"] = "central" });
|
||||
await host.StartAsync();
|
||||
var opts = host.Services.GetRequiredService<IOptions<NodeOptions>>().Value;
|
||||
Assert.Equal(8080, opts.Port);
|
||||
Assert.Equal("central", opts.Name);
|
||||
await host.StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class ConfigPreflightTests
|
||||
{
|
||||
private static IConfiguration Config(Dictionary<string, string?> values) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_all_failures()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Bogus", ["Node:RemotingPort"] = "0" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.Require("Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
|
||||
.RequirePort("Node:RemotingPort");
|
||||
Assert.False(pf.IsValid);
|
||||
Assert.Equal(2, pf.Failures.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void When_runs_block_only_if_condition_true()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Site" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.When(cfg["Node:Role"] == "Site",
|
||||
p => p.RequireValue("Node:SiteId"));
|
||||
Assert.False(pf.IsValid); // SiteId missing
|
||||
Assert.Contains(pf.Failures, f => f.Contains("Node:SiteId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void When_false_does_not_run_block()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Central" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.When(cfg["Node:Role"] == "Site", p => p.RequireValue("Node:SiteId"));
|
||||
Assert.True(pf.IsValid); // block skipped, no failure recorded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowIfInvalid_throws_aggregated_message()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Name"] = "" });
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid());
|
||||
Assert.StartsWith("Configuration validation failed:", ex.Message);
|
||||
Assert.Contains(" - Node:Name", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowIfInvalid_is_noop_when_valid()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Name"] = "ok" });
|
||||
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid(); // does not throw
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class OptionsValidatorBaseTests
|
||||
{
|
||||
private sealed class SampleOptions
|
||||
{
|
||||
public int Port { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SampleValidator : OptionsValidatorBase<SampleOptions>
|
||||
{
|
||||
protected override void Validate(ValidationBuilder v, SampleOptions o)
|
||||
{
|
||||
v.Port(o.Port, "Sample:Port");
|
||||
v.Required(o.Name, "Sample:Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_when_clean()
|
||||
{
|
||||
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 8080, Name = "ok" });
|
||||
Assert.True(r.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fails_and_reports_all_failures()
|
||||
{
|
||||
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 0, Name = "" });
|
||||
Assert.True(r.Failed);
|
||||
Assert.Equal(2, r.Failures!.Count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class ValidationBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(65535, true)]
|
||||
[InlineData(65536, false)]
|
||||
public void Port_validates_range(int port, bool valid)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.Port(port, "X:Port");
|
||||
Assert.Equal(valid, b.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" ", false)]
|
||||
[InlineData("ok", true)]
|
||||
public void Required_rejects_null_empty_whitespace(string? value, bool valid)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.Required(value, "X:Name");
|
||||
Assert.Equal(valid, b.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("host:5000", true)]
|
||||
[InlineData("host", false)]
|
||||
[InlineData("host:0", false)]
|
||||
[InlineData("host:notaport", false)]
|
||||
[InlineData("::1", false)]
|
||||
public void HostPort_validates_endpoint(string value, bool valid)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.HostPort(value, "X:Endpoint");
|
||||
Assert.Equal(valid, b.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositiveTimeSpan_rejects_zero_and_negative()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.PositiveTimeSpan(TimeSpan.Zero, "X:T1").PositiveTimeSpan(TimeSpan.FromSeconds(-1), "X:T2");
|
||||
Assert.Equal(2, b.Failures.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneOf_is_case_insensitive()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.OneOf("CENTRAL", new[] { "Central", "Site" }, "X:Role");
|
||||
Assert.True(b.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneOf_null_value_fails()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.OneOf(null, new[] { "Central", "Site" }, "X:Role");
|
||||
Assert.False(b.IsValid);
|
||||
Assert.Contains(b.Failures, f => f.Contains("X:Role"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinCount_requires_minimum()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.MinCount(new[] { "a" }, 2, "X:Seeds");
|
||||
Assert.False(b.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accumulates_all_failures_and_RequireThat_Add_work()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.Required(null, "A").RequireThat(false, "B failed").Add("C failed");
|
||||
Assert.Equal(3, b.Failures.Count);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Configuration\ZB.MOM.WW.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -21,6 +21,7 @@ specs and analyses that *drive* changes made in the individual repos.
|
||||
| UI Theme (layout / tokens / components) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Theme`) | [`ui-theme/`](ui-theme/) |
|
||||
| Health (readiness / liveness / active-node) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Health` lib (3 packages) | [`health/`](health/) |
|
||||
| Observability (metrics / traces / logs) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Telemetry` lib (2 packages) | [`observability/`](observability/) |
|
||||
| Config + validation (options / startup validation) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Configuration` lib (1 package) | [`configuration/`](configuration/) |
|
||||
| Audit (event model + writer seam) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Audit`) | [`audit/`](audit/) |
|
||||
|
||||
> Add a row when you start normalizing a new component. Status: `Draft` → `Reviewed` → `Adopting` → `Converged`.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Configuration validation — gaps & adoption backlog
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to adopt
|
||||
the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (27 tests) at
|
||||
[`../../ZB.MOM.WW.Configuration/`](../../ZB.MOM.WW.Configuration/) but **NOT YET ADOPTED** by any
|
||||
app — so every item below is an *adoption* item, not a library-build item. This mirrors the Auth /
|
||||
UI-Theme / Health pattern: the shared library is built first; adoption is opt-in and tracked here,
|
||||
not forced. (Unlike the observability pass, there is **no in-pass sister-repo adoption** in this
|
||||
release.)
|
||||
|
||||
Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
## Divergence vs spec
|
||||
|
||||
### §1 `IValidateOptions` base — failure accumulation (everyone hand-rolls it)
|
||||
|
||||
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| Uses `OptionsValidatorBase` | ⛔ n/a (no validators) | ⛔ hand-rolled | ⛔ hand-rolled ×4 |
|
||||
| Private `List<string>` accumulation | n/a | 🟡 `GatewayOptionsValidator` | 🟡 four validators |
|
||||
| Aggregates ALL failures | n/a | ✅ (yes, manually) | ✅ (yes, manually) |
|
||||
|
||||
MxGateway and ScadaBridge already accumulate all failures correctly — they just open-code the
|
||||
plumbing (the `List<string>`, the `Count == 0 ? Success : Fail` tail, and in MxGateway the
|
||||
`AddIfBlank`/`AddIfNotPositive` helpers). OtOpcUa has **no validators at all**.
|
||||
|
||||
→ **Gap B1:** MxGateway: `GatewayOptionsValidator` → `OptionsValidatorBase<GatewayOptions>`.
|
||||
→ **Gap B2:** ScadaBridge: four `*OptionsValidator` → `OptionsValidatorBase<T>`.
|
||||
→ **Gap B3:** OtOpcUa: *optionally* add `OptionsValidatorBase` subclasses for `Ldap`/`OpcUa` (no
|
||||
existing validators to migrate — additive only).
|
||||
|
||||
### §2 Rule primitives (re-implemented as private helpers)
|
||||
|
||||
| Primitive | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| required-string | ⛔ none | 🟡 `AddIfBlank` | 🟡 inline `IsNullOrWhiteSpace` |
|
||||
| port range | ⛔ none | 🟡 inline `Port` check | 🟡 inline (in `StartupValidator`) |
|
||||
| positive `TimeSpan` | ⛔ none | n/a | 🟡 inline `<= TimeSpan.Zero` ×7 |
|
||||
| one-of-set | ⛔ none | 🟡 inline enum/string checks | 🟡 inline `HashSet.Contains` |
|
||||
| min-count | ⛔ none | n/a | 🟡 inline `Count < n` |
|
||||
|
||||
The same five rules recur as private helpers / inline checks across both heavy consumers. The
|
||||
shared `ValidationBuilder` primitives (`Required`, `Port`, `HostPort`, `PositiveTimeSpan`, `OneOf`,
|
||||
`MinCount`) plus `RequireThat`/`Add` replace them with identical wording (the internal `Checks`
|
||||
seam).
|
||||
|
||||
→ **Gap P1:** MxGateway/ScadaBridge: re-express inline checks/helpers as `ValidationBuilder`
|
||||
primitives; keep app-specific rules (`.exe` path, heartbeat ordering, seed-node topology) as
|
||||
`RequireThat`/`Add`.
|
||||
|
||||
### §3 DI wiring — `AddValidatedOptions` (everyone open-codes the triple)
|
||||
|
||||
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| `bind + register-validator + ValidateOnStart` in one call | ⛔ bare `.Bind()`, no validate | ⛔ `AddGatewayConfiguration` open-codes it | ⛔ per-module `AddXxx` open-codes it ×4 |
|
||||
|
||||
MxGateway's `AddGatewayConfiguration` (`AddOptions().BindConfiguration().ValidateOnStart()` +
|
||||
`AddSingleton<IValidateOptions...>`) and ScadaBridge's four module extensions all spell out exactly
|
||||
what `AddValidatedOptions<TOptions, TValidator>(config, sectionPath)` collapses into one line.
|
||||
OtOpcUa binds with bare `.Bind()` and never validates.
|
||||
|
||||
→ **Gap W1:** MxGateway: `AddGatewayConfiguration` → `AddValidatedOptions`.
|
||||
→ **Gap W2:** ScadaBridge: four module `AddXxx` → `AddValidatedOptions`.
|
||||
→ **Gap W3:** OtOpcUa: replace bare `.Bind()` (`Program.cs:99`, `OtOpcUaServerHostedService.cs:63`)
|
||||
with `AddValidatedOptions` if validators are added (B3).
|
||||
|
||||
### §4 Pre-host preflight — `ConfigPreflight` (only ScadaBridge has the concern)
|
||||
|
||||
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| Pre-host raw-config validation | ⛔ none | ⛔ none (single host, no pre-Akka stage) | ✅ `StartupValidator` (open-coded) |
|
||||
| Message byte-compatible with `ConfigPreflight` | n/a | n/a | ✅ **confirmed** |
|
||||
|
||||
ScadaBridge's `StartupValidator` is the *reason* `ConfigPreflight` exists. Its thrown
|
||||
`InvalidOperationException` message is **byte-identical** to `ConfigPreflight.ThrowIfInvalid()`
|
||||
(`"Configuration validation failed:\n - <field> <reason>"`, verified against
|
||||
`ConfigPreflight.cs:63–68` and `StartupValidator.cs:81–83`), so the swap is behaviour-preserving.
|
||||
|
||||
→ **Gap F1:** ScadaBridge: `StartupValidator` → `ConfigPreflight` (gated on the byte-compatibility
|
||||
test). OtOpcUa/MxGateway have no pre-host stage — not applicable.
|
||||
|
||||
### §5 OtOpcUa has no startup options validation at all (surprise)
|
||||
|
||||
OtOpcUa has **zero** `IValidateOptions` / `ValidateOnStart` usages in `src/`. Its `Ldap` and
|
||||
`OpcUa` sections are bound and trusted; a bad value fails opaquely on first use (the exact failure
|
||||
mode ScadaBridge's `SecurityOptionsValidator` was written to prevent). This is an absence, not a
|
||||
drift — adoption here is *additive*, optional, and the lowest-stakes of the three.
|
||||
|
||||
→ **Gap A1:** OtOpcUa: add fail-fast validation for `Ldap` (required server/search-base) and any
|
||||
`OpcUa` invariants via `OptionsValidatorBase` + `AddValidatedOptions`. Optional; low priority.
|
||||
|
||||
### §6 Draft validation is out of scope (no gap)
|
||||
|
||||
OtOpcUa's `DraftValidator` / `DraftSnapshot` (runtime draft/snapshot validation of operator config
|
||||
*content*) is **not** options/config validation and is explicitly out of the shared library's scope
|
||||
(SPEC §0). It is **not a gap** and requires **no change** on adoption — listed here only to record
|
||||
that it was considered and deliberately excluded.
|
||||
|
||||
## Adoption backlog (ordered)
|
||||
|
||||
| # | Item | Projects | Priority | Effort | Risk | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | MxGateway: `GatewayOptionsValidator` → `OptionsValidatorBase`; helpers → primitives (Gaps B1, P1) | MxGateway | P2 | M | Low | One validator (~360 LOC); messages preserved verbatim |
|
||||
| 2 | MxGateway: `AddGatewayConfiguration` → `AddValidatedOptions` (Gap W1) | MxGateway | P2 | S | Low | Bundles with #1; pass `GatewayOptions.SectionName` as `sectionPath` |
|
||||
| 3 | ScadaBridge: four `*OptionsValidator` → `OptionsValidatorBase`; inline checks → primitives (Gaps B2, P1) | ScadaBridge | P2 | M | Low | Cluster/Security/HealthMonitoring/AuditLog; messages preserved |
|
||||
| 4 | ScadaBridge: four module `AddXxx` → `AddValidatedOptions` (Gap W2) | ScadaBridge | P2 | S | Low | Bundles with #3; keep HealthMonitoring idempotency guard |
|
||||
| 5 | ScadaBridge: `StartupValidator` → `ConfigPreflight` (Gap F1) | ScadaBridge | P2 | S | Low | Gated on byte-compatibility test; `Program.cs:39` call site unchanged |
|
||||
| 6 | OtOpcUa: add `Ldap`/`OpcUa` validators via `OptionsValidatorBase` + `AddValidatedOptions` (Gaps A1, B3, W3) | OtOpcUa | P3 | S | Low | Additive (no validators today); lowest stakes |
|
||||
|
||||
**Sequencing:** items #1–#2 (MxGateway) and #3–#5 (ScadaBridge) are independent and can land in
|
||||
either order; each pair lands in its own sister repo once the `0.1.0` nupkg is referenced. Item #6
|
||||
(OtOpcUa) is optional new work, deferrable indefinitely. No item is a breaking change — every
|
||||
migration is a behaviour-preserving plumbing swap (the `ConfigPreflight` swap is the only one that
|
||||
changes a thrown-message *implementation*, and it is byte-compatible by construction). There is no
|
||||
ops-coordination risk (unlike the observability `ms`→`s` / Meter-rename items) because no
|
||||
externally-observed contract (metric label, dashboard, wire format) changes.
|
||||
|
||||
## Decisions settled (no longer open)
|
||||
|
||||
- **Draft validation excluded (SETTLED):** OtOpcUa's `DraftValidator`/`DraftSnapshot` is runtime
|
||||
config-content validation, not startup options validation, and stays per-project. See SPEC §0 and
|
||||
[`current-state/otopcua/CURRENT-STATE.md`](current-state/otopcua/CURRENT-STATE.md).
|
||||
- **`ConfigPreflight` message envelope pinned (SETTLED):** the library reproduces ScadaBridge's
|
||||
`StartupValidator` envelope byte-for-byte (`InvalidOperationException`,
|
||||
`"Configuration validation failed:\n - <field> <reason>"`), so the migration is
|
||||
behaviour-preserving. Verified in `ConfigPreflightTests`.
|
||||
- **Single package, no ASP.NET Core dependency (SETTLED):** the library closes over only
|
||||
`Microsoft.Extensions.*` abstractions — validators run in plain DI, no framework reference. See
|
||||
[`shared-contract/ZB.MOM.WW.Configuration.md`](shared-contract/ZB.MOM.WW.Configuration.md).
|
||||
|
||||
## Decisions still open
|
||||
|
||||
- **Filesystem-path validity primitive:** MxGateway's `AddIfInvalidPath` (valid-path + `.exe`
|
||||
extension) is currently mapped to a custom `RequireThat`/`Add` rule. If a second app grows the
|
||||
same need, consider promoting a `Path`/`FilePath` primitive to `ValidationBuilder` — for now it
|
||||
stays app-specific.
|
||||
- **No-validator ScadaBridge modules:** `Communication`, `DataConnectionLayer`, `Transport`,
|
||||
`Notification*`, etc. bind options without validation today. Whether to add validators (and thus
|
||||
`AddValidatedOptions`) for them is a per-module call, out of scope for the initial adoption.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Configuration validation (config binding + startup validation)
|
||||
|
||||
Normalized component for **startup configuration validation** across the three sister projects.
|
||||
**Goal: path to shared code** — converge the apps onto one `IValidateOptions` failure-accumulation
|
||||
base, a shared set of rule primitives, a single bind+validate+`ValidateOnStart` DI helper, and a
|
||||
pre-host raw-config aggregator, extracted as the `ZB.MOM.WW.Configuration` library (single package),
|
||||
while each app keeps its own options classes and domain rules.
|
||||
|
||||
- The one target: [`spec/SPEC.md`](spec/SPEC.md)
|
||||
- The shared library (paper API): [`shared-contract/ZB.MOM.WW.Configuration.md`](shared-contract/ZB.MOM.WW.Configuration.md)
|
||||
- Divergences + adoption backlog: [`GAPS.md`](GAPS.md)
|
||||
- Current state, per project: [`current-state/`](current-state/)
|
||||
|
||||
## Why config validation is a normalization candidate
|
||||
|
||||
All three apps fail-fast on bad configuration at startup — and all three hand-roll the same
|
||||
plumbing to do it:
|
||||
|
||||
- **OtOpcUa** has **no startup options validation at all** — `Ldap`/`OpcUa` are bound with bare
|
||||
`.Bind()` and trusted; a bad value fails opaquely on first use. (Its `DraftValidator` is runtime
|
||||
*config-content* validation, a different concern, out of scope.)
|
||||
- **MxAccessGateway** has one large `GatewayOptionsValidator` (~360 LOC, nine sub-validators) with a
|
||||
private `List<string>` accumulator and `AddIfBlank`/`AddIfNotPositive`/`AddIfInvalidPath` helpers,
|
||||
wired through a bespoke `AddGatewayConfiguration` extension.
|
||||
- **ScadaBridge** is the heaviest: **four** per-module `*OptionsValidator` (Cluster / Security /
|
||||
HealthMonitoring / AuditLog), each open-coding the same accumulation, **plus** a raw-config
|
||||
pre-Akka `StartupValidator`.
|
||||
|
||||
The common core — accumulate-all-failures `IValidateOptions`, reusable rule primitives,
|
||||
`AddValidatedOptions`, and a `ConfigPreflight` that generalizes `StartupValidator` — is genuinely
|
||||
shareable; the **options classes and domain rules stay per-project**. The unifying detail:
|
||||
`ConfigPreflight.ThrowIfInvalid()` reproduces ScadaBridge's `StartupValidator` thrown message
|
||||
**byte-for-byte**, so the heaviest migration is behaviour-preserving.
|
||||
|
||||
## Status by project
|
||||
|
||||
| Project | Options validators today | Pre-host preflight | Failure accumulation | Adoption status |
|
||||
|---|---|---|---|---|
|
||||
| **OtOpcUa** | ⛔ **none** (bare `.Bind()`; `DraftValidator` is out-of-scope runtime validation) | ⛔ none | n/a | Not started (additive, optional) |
|
||||
| **MxAccessGateway** | 🟡 `GatewayOptionsValidator` (hand-rolled `IValidateOptions`) | ⛔ none | 🟡 manual `List<string>` | Not started (follow-on) |
|
||||
| **ScadaBridge** | 🟡 four `*OptionsValidator` (hand-rolled) | ✅ `StartupValidator` (raw config, pre-Akka) | 🟡 manual `List<string>` ×4 | Not started (follow-on; heaviest) |
|
||||
|
||||
See each project's [`current-state/<project>/CURRENT-STATE.md`](current-state/) for the
|
||||
code-verified detail and its adoption plan.
|
||||
|
||||
## Normalized vs. left per-project
|
||||
|
||||
**Normalized (the shared target):**
|
||||
|
||||
- `OptionsValidatorBase<TOptions>` — abstract `IValidateOptions<TOptions>`; override
|
||||
`protected void Validate(ValidationBuilder, TOptions)`; the base aggregates **all** failures and
|
||||
returns `Success` only when clean.
|
||||
- `ValidationBuilder` rule primitives — `Required`, `Port`, `HostPort`, `PositiveTimeSpan`,
|
||||
`OneOf`, `MinCount`, plus `RequireThat`/`Add` for custom and cross-field rules; consistent
|
||||
`"<field> <reason>"` wording via the internal `Checks` seam.
|
||||
- `AddValidatedOptions<TOptions, TValidator>(IConfiguration, sectionPath)` — bind + register
|
||||
validator + `ValidateOnStart` in one DI call; returns `OptionsBuilder<TOptions>`.
|
||||
- `ConfigPreflight` — fluent pre-host raw-`IConfiguration` aggregator (`For`/`Require`/`RequireValue`/
|
||||
`RequirePort`/`When`/`ThrowIfInvalid`); generalizes `StartupValidator`, with a byte-compatible
|
||||
thrown message.
|
||||
- The error-handling contract: accumulate ALL failures; two surfacing paths
|
||||
(`OptionsValidationException` at host start via `ValidateOnStart`, vs
|
||||
`ConfigPreflight.ThrowIfInvalid()`'s `InvalidOperationException`); `"<field> <reason>"` messages.
|
||||
|
||||
**Left per-project (not forced together):**
|
||||
|
||||
- Each app's options classes (`GatewayOptions`, `ClusterOptions`, `SecurityOptions`,
|
||||
`HealthMonitoringOptions`, `AuditLogOptions`, `NodeOptions`, …) and all of their domain rules —
|
||||
worker `.exe` paths, split-brain strategy, Akka heartbeat/threshold ordering, audit retention
|
||||
bounds, gRPC-port-vs-remoting-port topology, etc.
|
||||
- OtOpcUa's runtime draft/snapshot validation (`DraftValidator` + `DraftSnapshot`) — config-content
|
||||
validation, **out of scope** entirely.
|
||||
|
||||
## Package structure
|
||||
|
||||
`ZB.MOM.WW.Configuration` ships as a **single package, one DLL** — no third-party packages, no
|
||||
ASP.NET Core framework reference:
|
||||
|
||||
| Package | Contents | Consumers |
|
||||
|---|---|---|
|
||||
| `ZB.MOM.WW.Configuration` | `OptionsValidatorBase<TOptions>`, `ValidationBuilder`, `ServiceCollectionExtensions.AddValidatedOptions`, `ConfigPreflight`, internal `Checks` | All three (ScadaBridge heaviest) |
|
||||
|
||||
Dependency closure: `Microsoft.Extensions.{Options, Options.ConfigurationExtensions,
|
||||
Configuration.Abstractions, DependencyInjection.Abstractions}`.
|
||||
|
||||
## Component status
|
||||
|
||||
**Status: Draft. Library BUILT @ 0.1.0; NOT YET ADOPTED by the three apps. Adoption is the
|
||||
backlog** (tracked in [`GAPS.md`](GAPS.md)). Unlike the observability pass, this release carries
|
||||
**no in-pass sister-repo adoption** — it is library-only.
|
||||
|
||||
The shared library lives at
|
||||
[`~/Desktop/scadaproj/ZB.MOM.WW.Configuration/`](../../ZB.MOM.WW.Configuration/) (.NET 10; single
|
||||
package; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0). Build/test/pack from `ZB.MOM.WW.Configuration/`:
|
||||
|
||||
```bash
|
||||
dotnet test ZB.MOM.WW.Configuration.slnx
|
||||
dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Shared library: `ZB.MOM.WW.Configuration`
|
||||
|
||||
The public surface that extracts the startup configuration-validation plumbing the three
|
||||
projects share. Realizes [`../spec/SPEC.md`](../spec/SPEC.md). **BUILT @ `0.1.0`** — the
|
||||
implementation lives at [`../../../ZB.MOM.WW.Configuration/`](../../../ZB.MOM.WW.Configuration/)
|
||||
(.NET 10; single package; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0). **Not yet adopted** by the
|
||||
three apps — adoption is the follow-on tracked in [`../GAPS.md`](../GAPS.md).
|
||||
|
||||
This doc is the contract; the source is authoritative. Signatures below match the built source
|
||||
(`src/ZB.MOM.WW.Configuration/*.cs`) verified at `0.1.0`.
|
||||
|
||||
## Package (.NET 10)
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Configuration # OptionsValidatorBase, ValidationBuilder, AddValidatedOptions, ConfigPreflight
|
||||
```
|
||||
|
||||
A **single package, one DLL**. Minimal dependency closure — only `Microsoft.Extensions.*`
|
||||
abstractions, **no** third-party packages and **no** ASP.NET Core framework reference:
|
||||
|
||||
| Dependency | Why |
|
||||
|---|---|
|
||||
| `Microsoft.Extensions.Options` | `IValidateOptions<T>`, `ValidateOptionsResult`, `OptionsBuilder<T>` |
|
||||
| `Microsoft.Extensions.Options.ConfigurationExtensions` | `.Bind(IConfigurationSection)` on the options builder |
|
||||
| `Microsoft.Extensions.Configuration.Abstractions` | `IConfiguration` / `GetSection` for `AddValidatedOptions` + `ConfigPreflight` |
|
||||
| `Microsoft.Extensions.DependencyInjection.Abstractions` | `IServiceCollection`, `AddOptions`, `AddSingleton` |
|
||||
|
||||
Library, not a service — linked into each app at build time; all validation runs in-process at
|
||||
startup. Published to the Gitea NuGet feed; SemVer.
|
||||
|
||||
---
|
||||
|
||||
## `OptionsValidatorBase<TOptions>`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// Base class for IValidateOptions<TOptions> that removes the failure-accumulation plumbing.
|
||||
/// Override Validate(builder, options); the base aggregates ALL failures and returns
|
||||
/// ValidateOptionsResult.Success only when none were recorded.
|
||||
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
// Guards null, runs the override against a fresh ValidationBuilder, and returns
|
||||
// Success when builder.IsValid else Fail(builder.Failures).
|
||||
public ValidateOptionsResult Validate(string? name, TOptions options);
|
||||
|
||||
// Record failures for `options` on `builder`. Never return early — record everything.
|
||||
protected abstract void Validate(ValidationBuilder builder, TOptions options);
|
||||
}
|
||||
```
|
||||
|
||||
The override is the only thing a consumer writes. Accumulation, the `Success`/`Fail` decision,
|
||||
and null-guarding are owned by the base.
|
||||
|
||||
---
|
||||
|
||||
## `ValidationBuilder`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// Accumulates validation failures for a bound options object. Passed into the Validate override;
|
||||
/// each primitive checks a value and appends a "<field> <reason>" message on failure.
|
||||
public sealed class ValidationBuilder
|
||||
{
|
||||
public IReadOnlyList<string> Failures { get; } // accumulated messages (empty when valid)
|
||||
public bool IsValid { get; } // true when no failures recorded
|
||||
|
||||
// Escape hatches (custom + cross-field rules):
|
||||
public ValidationBuilder RequireThat(bool ok, string message); // records message when !ok
|
||||
public ValidationBuilder Add(string message); // unconditional failure
|
||||
|
||||
// Rule primitives (each delegates wording to internal Checks):
|
||||
public ValidationBuilder Required(string? value, string field);
|
||||
public ValidationBuilder Port(int value, string field);
|
||||
public ValidationBuilder HostPort(string? value, string field);
|
||||
public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field);
|
||||
public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field);
|
||||
public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field);
|
||||
}
|
||||
```
|
||||
|
||||
All methods are chainable (return `this`). `OneOf` treats a `null` value as a failure — call
|
||||
`Required` first if you want a "required" message instead of a "must be one of" message.
|
||||
|
||||
---
|
||||
|
||||
## `ServiceCollectionExtensions.AddValidatedOptions`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// Binds TOptions to the section at sectionPath, registers TValidator as its
|
||||
/// IValidateOptions<TOptions> (singleton), and enables ValidateOnStart so a bad
|
||||
/// configuration fails fast at host start. Returns the OptionsBuilder for chaining.
|
||||
public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
|
||||
this IServiceCollection services, IConfiguration configuration, string sectionPath)
|
||||
where TOptions : class
|
||||
where TValidator : class, IValidateOptions<TOptions>;
|
||||
}
|
||||
```
|
||||
|
||||
Guards null `services`/`configuration` and whitespace `sectionPath`. The validator is registered
|
||||
as a **singleton** (it backs the singleton options factory) — it must be singleton-safe (no
|
||||
scoped dependencies). Bad sections surface as **`OptionsValidationException`** at host start.
|
||||
|
||||
---
|
||||
|
||||
## `ConfigPreflight`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// Fluent aggregator for validating raw IConfiguration BEFORE the host/DI container exists
|
||||
/// (pre-Akka startup). Collects all failures and surfaces them together via ThrowIfInvalid.
|
||||
public sealed class ConfigPreflight
|
||||
{
|
||||
public static ConfigPreflight For(IConfiguration configuration); // start a preflight
|
||||
|
||||
public IReadOnlyList<string> Failures { get; } // accumulated (empty when valid)
|
||||
public bool IsValid { get; }
|
||||
|
||||
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason); // "<key> <reason>" on fail
|
||||
public ConfigPreflight RequireValue(string key); // non-empty value at key
|
||||
public ConfigPreflight RequirePort(string key); // integer TCP port 1-65535 at key
|
||||
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block); // role-conditional rules
|
||||
|
||||
/// Throws InvalidOperationException listing all failures when invalid; otherwise returns.
|
||||
/// Message envelope (byte-compatible with ScadaBridge StartupValidator):
|
||||
/// "Configuration validation failed:\n - <field> <reason>\n - <field> <reason>"
|
||||
public void ThrowIfInvalid();
|
||||
}
|
||||
```
|
||||
|
||||
`Require`/`RequireValue`/`RequirePort` guard a whitespace `key`. `ThrowIfInvalid()` is the only
|
||||
surfacing path — call it last. The message envelope is pinned to match ScadaBridge's
|
||||
`StartupValidator` (see [`../current-state/scadabridge/CURRENT-STATE.md`](../current-state/scadabridge/CURRENT-STATE.md)
|
||||
and SPEC §4); the swap is behaviour-preserving.
|
||||
|
||||
---
|
||||
|
||||
## Internal `Checks` seam
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
// internal — shared by ValidationBuilder (bound options) and ConfigPreflight (raw config).
|
||||
// Each method returns null when valid, else a "<field> <reason>" message. Centralizing the
|
||||
// wording keeps a given rule identical across both front-ends.
|
||||
internal static class Checks
|
||||
{
|
||||
internal static string? Required(string? value, string field);
|
||||
internal static string? Port(int value, string field);
|
||||
internal static string? PortValue(string? raw, string field); // parse + range, for raw-config callers
|
||||
internal static string? HostPort(string? value, string field); // non-bracketed host:port; rejects [::1]:port
|
||||
internal static string? PositiveTimeSpan(TimeSpan value, string field);
|
||||
internal static string? OneOf(string? value, IReadOnlyCollection<string> allowed, string field);
|
||||
internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field);
|
||||
}
|
||||
```
|
||||
|
||||
`Checks` is the **single source of failure wording**. `ValidationBuilder.Port` uses `Checks.Port`
|
||||
(typed `int`); `ConfigPreflight.RequirePort` uses `Checks.PortValue` (raw string → parse → range),
|
||||
so a port failure reads the same whether it came from a bound options object or a raw config key.
|
||||
Not public — consumers get the wording through the primitives, not the seam.
|
||||
|
||||
---
|
||||
|
||||
## Consumer matrix
|
||||
|
||||
| Consumer | Package | What it adopts | Weight |
|
||||
|---|---|---|---|
|
||||
| **ScadaBridge** | `ZB.MOM.WW.Configuration` | Four `*OptionsValidator` → `OptionsValidatorBase`; four module `AddXxx` → `AddValidatedOptions`; `StartupValidator` → `ConfigPreflight` (byte-compatible). | **Heaviest** — the most validators + the preflight. |
|
||||
| **MxGateway** | `ZB.MOM.WW.Configuration` | `GatewayOptionsValidator` → `OptionsValidatorBase` (drop the `List<string>` + `AddIfBlank`/`AddIfNotPositive`/`AddIfInvalidPath` helpers); `AddGatewayConfiguration` → `AddValidatedOptions`. | Medium — one large validator. |
|
||||
| **OtOpcUa** | `ZB.MOM.WW.Configuration` | *Optional* — add `OptionsValidatorBase` subclasses + `AddValidatedOptions` for `Ldap` / `OpcUa` sections (currently unvalidated). `DraftValidator`/`DraftSnapshot` stay per-project (out of scope). | **Lightest** — no validators today. |
|
||||
|
||||
All three consume the same single package; none needs ASP.NET Core. The net48 x86 mxaccessgw
|
||||
worker does no `IConfiguration` validation and is excluded.
|
||||
|
||||
See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Configuration validation — normalized target spec
|
||||
|
||||
Status: **Draft**. The single design the sister projects converge on for **startup
|
||||
configuration validation**. Derived from the three code-verified current-state docs
|
||||
(`../current-state/`). Goal is *path to shared code*
|
||||
(`../shared-contract/ZB.MOM.WW.Configuration.md`), so each normalized section maps to a shared
|
||||
library seam. The library is **already built** at
|
||||
[`../../../ZB.MOM.WW.Configuration/`](../../../ZB.MOM.WW.Configuration/) (`0.1.0`, 27 tests).
|
||||
|
||||
## 0. Scope
|
||||
|
||||
The common concern is **fail-fast validation of configuration at process startup**: bind an
|
||||
`appsettings.json` / environment section to a typed options object (or read raw keys before the
|
||||
host exists), check every field, and refuse to start when anything is wrong — surfacing **all**
|
||||
problems at once so an operator fixes them in one edit rather than one boot-loop per typo. All
|
||||
three apps already do this; they do it with three private copies of the same plumbing.
|
||||
|
||||
**Normalized here** (goes in the shared `ZB.MOM.WW.Configuration` library):
|
||||
|
||||
- **The `IValidateOptions<T>` failure-accumulation convention.** Every app hand-rolls a
|
||||
`List<string> failures`, a pile of `if (...) failures.Add(...)`, and the
|
||||
`failures.Count == 0 ? Success : Fail(failures)` tail. That plumbing becomes
|
||||
`OptionsValidatorBase<TOptions>`: override `protected void Validate(ValidationBuilder, TOptions)`,
|
||||
record failures on the builder, and the base aggregates them and returns a single
|
||||
`ValidateOptionsResult` (Success only when the builder is clean).
|
||||
- **Reusable rule primitives.** The same checks recur across apps — required-string, TCP port
|
||||
range, `host:port` endpoint, positive `TimeSpan`, one-of-a-set, minimum collection count. They
|
||||
become `ValidationBuilder` primitives (`Required`, `Port`, `HostPort`, `PositiveTimeSpan`,
|
||||
`OneOf`, `MinCount`) plus `RequireThat(bool, message)` / `Add(message)` escape hatches for
|
||||
custom and cross-field rules. Wording is centralized in an internal `Checks` seam so a
|
||||
given rule reads identically everywhere.
|
||||
- **`AddValidatedOptions<TOptions, TValidator>(IConfiguration, sectionPath)`** — one DI call that
|
||||
binds the section, registers the validator as the options' `IValidateOptions<TOptions>`, and
|
||||
enables `ValidateOnStart()`. Replaces the per-module `AddOptions().Bind(...).ValidateOnStart()`
|
||||
+ `AddSingleton<IValidateOptions<...>, ...>()` pair that each app open-codes.
|
||||
- **The pre-host `ConfigPreflight` aggregator** — a fluent checker over raw `IConfiguration` for
|
||||
the keys that must be valid *before* the host / DI container / actor system is built (node
|
||||
role, remoting port, site id). Generalizes ScadaBridge's `StartupValidator`. Fluent surface:
|
||||
`For(config)`, `.Require(key, predicate, reason)`, `.RequireValue(key)`, `.RequirePort(key)`,
|
||||
`.When(condition, block)` (role-conditional rules), `.ThrowIfInvalid()`.
|
||||
|
||||
**The error-handling contract** (shared across both front-ends):
|
||||
|
||||
- **Accumulate ALL failures.** Never short-circuit on the first failure — collect every problem
|
||||
and surface them together. (`OptionsValidatorBase` and `ConfigPreflight` both do this; it is
|
||||
the behaviour every app already wanted.)
|
||||
- **Two surfacing paths**, by where validation runs:
|
||||
1. **Options bound through DI** → `ValidateOnStart()` raises an
|
||||
**`OptionsValidationException`** at host start (the .NET options pipeline aggregates the
|
||||
failures). This is the `AddValidatedOptions` path.
|
||||
2. **Raw config, pre-host** → `ConfigPreflight.ThrowIfInvalid()` throws an
|
||||
**`InvalidOperationException`** listing all failures.
|
||||
- **Message format `"<field> <reason>"`** for each individual failure, produced by the shared
|
||||
`Checks` primitives (e.g. `"ScadaBridge:Node:RemotingPort must be between 1 and 65535 (was '0')"`).
|
||||
`ConfigPreflight.ThrowIfInvalid()` wraps the accumulated lines in the exact envelope
|
||||
ScadaBridge's `StartupValidator` uses today (§4) so the migration is byte-compatible.
|
||||
|
||||
**Explicitly NOT normalized** (domain-specific — stays per project):
|
||||
|
||||
- **Each app's options classes and their domain rules.** `GatewayOptions` (worker exe path,
|
||||
heartbeat grace ≥ interval, TLS validity years), `ClusterOptions` (split-brain strategy,
|
||||
`MinNrOfMembers == 1`, heartbeat ≪ failure-detection), `SecurityOptions` (LDAP server /
|
||||
search base), `HealthMonitoringOptions` (positive `PeriodicTimer` intervals),
|
||||
`AuditLogOptions` (payload caps, retention bounds), and ScadaBridge's `Node` topology rules
|
||||
(gRPC port ≠ remoting port, seed nodes must not target the gRPC port) all stay where they
|
||||
live. Only the *plumbing they sit on* is shared; the *rules* are theirs.
|
||||
- **OtOpcUa's runtime draft/snapshot validation** (`DraftValidator` + `DraftSnapshot`). This is
|
||||
**not** options/config validation at all — it is managed pre-publish validation of an operator's
|
||||
*configuration draft* (UNS segment regex, EquipmentId derivation, cross-cluster namespace
|
||||
binding, reservation pre-flight), run in the publish pipeline against database rows, not against
|
||||
`IConfiguration`. It shares only a *philosophy* (return every failure in one pass) with this
|
||||
component and is **out of scope** for the shared library. It stays entirely in OtOpcUa.
|
||||
|
||||
## 1. `IValidateOptions` base — `OptionsValidatorBase<TOptions>`
|
||||
|
||||
The headline plumbing fix. Today each validator re-implements: the `Validate(string?, TOptions)`
|
||||
signature, a local `List<string>`, the `failures.Count == 0 ? Success : Fail(failures)` tail,
|
||||
and (in several) private `AddIfBlank` / `AddIfNotPositive` helpers. The base owns all of that:
|
||||
|
||||
```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, ["keep-oldest"], "ClusterOptions.SplitBrainResolverStrategy");
|
||||
v.PositiveTimeSpan(o.StableAfter, "ClusterOptions.StableAfter");
|
||||
v.RequireThat(o.MinNrOfMembers == 1,
|
||||
$"ClusterOptions.MinNrOfMembers must be 1 (was {o.MinNrOfMembers})");
|
||||
// cross-field rule:
|
||||
v.RequireThat(o.HeartbeatInterval < o.FailureDetectionThreshold,
|
||||
"ClusterOptions.HeartbeatInterval must be below FailureDetectionThreshold");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`OptionsValidatorBase<TOptions>.Validate(string?, TOptions)` guards null, creates a
|
||||
`ValidationBuilder`, calls the override, and returns `Success` only when `builder.IsValid`.
|
||||
**Accumulation is automatic** — the override never returns early; it records everything.
|
||||
|
||||
## 2. Rule primitives — `ValidationBuilder`
|
||||
|
||||
`ValidationBuilder` is the accumulator passed into the override. Primitives both check a value
|
||||
and append a consistently-worded `"<field> <reason>"` message on failure; escape hatches cover
|
||||
the rest:
|
||||
|
||||
| Primitive | Checks | Failure wording (from `Checks`) |
|
||||
|---|---|---|
|
||||
| `Required(value, field)` | non-null, non-whitespace string | `"<field> is required"` |
|
||||
| `Port(value, field)` | int in 1–65535 | `"<field> must be between 1 and 65535 (was <value>)"` |
|
||||
| `HostPort(value, field)` | `host:port` with port 1–65535 | `"<field> must be 'host:port' with port 1-65535 (was '<value>')"` |
|
||||
| `PositiveTimeSpan(value, field)` | `> TimeSpan.Zero` | `"<field> must be a positive duration (was <value>)"` |
|
||||
| `OneOf(value, allowed, field)` | case-insensitive membership | `"<field> must be one of [<allowed>] (was '<value>')"` |
|
||||
| `MinCount(value, min, field)` | collection ≥ `min` items | `"<field> must contain at least <min> item(s) (had <n>)"` |
|
||||
| `RequireThat(ok, message)` | arbitrary boolean (cross-field, custom) | caller-supplied |
|
||||
| `Add(message)` | unconditional failure | caller-supplied |
|
||||
|
||||
Properties: `Failures` (read-only accumulated list) and `IsValid`. Every method returns the
|
||||
builder for chaining. `Add`/`RequireThat` carry the rules that are genuinely app-specific (e.g.
|
||||
MxGateway's "ExecutablePath must point to a .exe", ScadaBridge's heartbeat-vs-threshold
|
||||
ordering) without forcing them into a primitive.
|
||||
|
||||
## 3. DI wiring — `AddValidatedOptions`
|
||||
|
||||
```csharp
|
||||
builder.Services.AddValidatedOptions<ClusterOptions, ClusterOptionsValidator>(
|
||||
builder.Configuration, "ScadaBridge:Cluster");
|
||||
```
|
||||
|
||||
Binds `ScadaBridge:Cluster` → `ClusterOptions`, registers `ClusterOptionsValidator` as a
|
||||
singleton `IValidateOptions<ClusterOptions>`, and calls `ValidateOnStart()`. Returns the
|
||||
`OptionsBuilder<TOptions>` for further chaining (e.g. `.PostConfigure(...)`). This collapses the
|
||||
three-line idiom every module repeats (`AddOptions().Bind(...).ValidateOnStart()` +
|
||||
`AddSingleton<IValidateOptions<...>, ...>()`) into one call.
|
||||
|
||||
> The validator is registered as a **singleton** (it backs the singleton options factory). It
|
||||
> must be singleton-safe — no scoped dependencies. All current validators are stateless, so this
|
||||
> holds.
|
||||
|
||||
When a section bound this way fails, the .NET options pipeline raises **`OptionsValidationException`**
|
||||
at host start (because of `ValidateOnStart()`), with all accumulated messages.
|
||||
|
||||
## 4. Pre-host preflight — `ConfigPreflight`
|
||||
|
||||
For keys that must be valid **before** the host / DI / actor system exists, `ConfigPreflight`
|
||||
reads raw `IConfiguration` and accumulates failures the same way:
|
||||
|
||||
```csharp
|
||||
ConfigPreflight.For(configuration)
|
||||
.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"))
|
||||
.ThrowIfInvalid();
|
||||
```
|
||||
|
||||
`.ThrowIfInvalid()` throws **`InvalidOperationException`** when any failure was recorded, with
|
||||
this exact envelope:
|
||||
|
||||
```
|
||||
Configuration validation failed:
|
||||
- <field> <reason>
|
||||
- <field> <reason>
|
||||
```
|
||||
|
||||
> **Byte-compatibility with ScadaBridge's `StartupValidator`.** ScadaBridge's
|
||||
> `StartupValidator.Validate` throws
|
||||
> `$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}"`.
|
||||
> `ConfigPreflight.ThrowIfInvalid()` produces the **identical** string
|
||||
> (`"Configuration validation failed:\n" + the same `" - <field> <reason>"` lines, `\n`-joined`).
|
||||
> The migration is a behaviour-preserving swap: same exception type
|
||||
> (`InvalidOperationException`), same message bytes. This is verified in the library's
|
||||
> `ConfigPreflightTests` and is the reason the message format is pinned in §0.
|
||||
|
||||
`.When(condition, block)` carries role-conditional rules (ScadaBridge only validates database /
|
||||
security / gRPC-port keys when the node is `Central` or `Site` respectively) without an `if` ladder.
|
||||
|
||||
## 5. Per-project migration
|
||||
|
||||
| Project | Current state | Primary gaps | What normalizes |
|
||||
|---|---|---|---|
|
||||
| **OtOpcUa** | **No options validation at all** — options bound with bare `.Bind()` (`LdapOptions`, `OpcUa`); zero `IValidateOptions` / `ValidateOnStart` in the repo. Only validator is `DraftValidator` (runtime draft/snapshot, **out of scope**). | No startup validation of `Ldap` / `OpcUa` sections — a bad value fails opaquely on first use. | *Optional* adoption: add `OptionsValidatorBase` subclasses + `AddValidatedOptions` for the sections worth guarding. `DraftValidator`/`DraftSnapshot` stay per-project untouched. Lightest consumer. |
|
||||
| **MxGateway** | One large `GatewayOptionsValidator : IValidateOptions<GatewayOptions>` (~360 LOC, 9 sub-validators, private `AddIfBlank`/`AddIfNotPositive`/`AddIfInvalidPath` helpers); wired via `AddGatewayConfiguration` (`AddOptions().BindConfiguration().ValidateOnStart()`). | Hand-rolled accumulation + helpers duplicate the base; bespoke DI wiring duplicates `AddValidatedOptions`. | `GatewayOptionsValidator` → `OptionsValidatorBase<GatewayOptions>` (delete the `List<string>`/tail/helpers; keep the domain rules); `AddGatewayConfiguration` → `AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>`. Domain rules unchanged. |
|
||||
| **ScadaBridge** | **Heaviest.** Four per-module `*OptionsValidator : IValidateOptions<T>` (Cluster / Security / HealthMonitoring / AuditLog) each with their own `List<string>` accumulation, wired through bespoke `AddXxx` extensions; **plus** a raw-config pre-Akka `StartupValidator`. | Four copies of the accumulation plumbing + bespoke DI wiring; `StartupValidator` open-codes the preflight envelope. | Each `*OptionsValidator` → `OptionsValidatorBase<T>`; each module's `AddXxx` → `AddValidatedOptions`; `StartupValidator` → `ConfigPreflight` (byte-compatible message, §4). Domain rules unchanged. |
|
||||
|
||||
> No sister-repo adoption is in scope for this release — the library is built; adoption is the
|
||||
> follow-on tracked in [`../GAPS.md`](../GAPS.md). (Unlike the observability pass, which carried
|
||||
> one in-pass MxGateway adoption, this pass is library-only.)
|
||||
|
||||
## 6. Acceptance (what "converged" means)
|
||||
|
||||
A project is converged when: (a) every options validator it owns derives from
|
||||
`OptionsValidatorBase<TOptions>` and records failures on the supplied `ValidationBuilder` (no
|
||||
private `List<string>` plumbing, no early return); (b) every bind-and-validate registration goes
|
||||
through `AddValidatedOptions<TOptions, TValidator>(config, sectionPath)`; (c) any pre-host raw-config
|
||||
checks go through `ConfigPreflight` and surface via `ThrowIfInvalid()`; (d) all validation
|
||||
**accumulates every failure** and surfaces them together (`OptionsValidationException` at host
|
||||
start, or `InvalidOperationException` from `ConfigPreflight`); and (e) failure wording for the
|
||||
shared primitives comes from the library's `Checks` seam, identical across the fleet. Each app's
|
||||
**options classes and domain rules stay its own**; only the plumbing is shared. OtOpcUa's
|
||||
`DraftValidator` is explicitly exempt — it is not part of the converged surface.
|
||||
@@ -8,8 +8,11 @@ startup-options-validation toolkit — a failure-accumulating `IValidateOptions<
|
||||
rule primitives, a bind+validate+`ValidateOnStart` DI helper, and a pre-host `ConfigPreflight`
|
||||
aggregator — removing the duplicated validation plumbing the three sister apps each hand-roll.
|
||||
|
||||
**Architecture:** A new standalone nested repo (`~/Desktop/scadaproj/ZB.MOM.WW.Configuration`),
|
||||
.NET 10, one library project `ZB.MOM.WW.Configuration` with four public types
|
||||
**Architecture:** A new self-contained solution directory committed into the outer `scadaproj`
|
||||
repo at `~/Desktop/scadaproj/ZB.MOM.WW.Configuration` (same layout as the sibling
|
||||
`ZB.MOM.WW.Telemetry`/`Health`/`Auth`/`Theme` — regular tracked files, **not** a submodule and
|
||||
**not** a separate `.git`; build output `bin/`/`obj/`/`artifacts/` is gitignored). .NET 10, one
|
||||
library project `ZB.MOM.WW.Configuration` with four public types
|
||||
(`OptionsValidatorBase<TOptions>`, `ValidationBuilder`, `ServiceCollectionExtensions`,
|
||||
`ConfigPreflight`) over one internal `Checks` helper that keeps rule wording identical across the
|
||||
two front-ends (options-object validation vs raw-`IConfiguration` preflight). Scope is **startup
|
||||
@@ -61,9 +64,10 @@ format is `"<field> <reason>"`.
|
||||
|
||||
**Conventions for every task:** TDD — failing test first, minimal impl, green, commit. File-scoped
|
||||
namespaces, `sealed` by default, XML doc comments on public members (match the sibling libs).
|
||||
Library work is committed inside the nested repo `~/Desktop/scadaproj/ZB.MOM.WW.Configuration`;
|
||||
docs/registry work is committed in the outer `scadaproj` repo. The `Files:` block IS the
|
||||
`files_to_edit` contract.
|
||||
**All work — library and docs — is committed to the outer `scadaproj` repo on branch
|
||||
`feat/zb-mom-ww-configuration`** (the library is tracked files inside it, like the sibling libs;
|
||||
there is no separate nested `.git`). `dotnet build`/`test` may `cd` into the library dir, but every
|
||||
`git commit` targets the outer repo. The `Files:` block IS the `files_to_edit` contract.
|
||||
|
||||
**Source references (read-only, to verify current-state against — do NOT modify):**
|
||||
- OtOpcUa: `~/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/{DraftValidator,DraftSnapshot}.cs` (draft validation stays per-project)
|
||||
@@ -243,17 +247,17 @@ plan; README status table links resolve.
|
||||
</Solution>
|
||||
```
|
||||
|
||||
**Step 6: init repo + verify restore/build**
|
||||
**Step 6: verify restore/build** (NO `git init` — the library is tracked inside the outer repo)
|
||||
```bash
|
||||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && git init -q
|
||||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration
|
||||
dotnet build ZB.MOM.WW.Configuration.slnx
|
||||
```
|
||||
Expected: build succeeds (0 source files yet → empty assembly is fine).
|
||||
|
||||
**Step 7: Commit** (nested repo)
|
||||
**Step 7: Commit** (outer repo, branch `feat/zb-mom-ww-configuration`)
|
||||
```bash
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration commit -m "chore: scaffold ZB.MOM.WW.Configuration solution"
|
||||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||||
git -C ~/Desktop/scadaproj commit -m "chore: scaffold ZB.MOM.WW.Configuration solution"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -463,8 +467,8 @@ cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||||
|
||||
**Step 6: Commit** (nested repo)
|
||||
```bash
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration commit -m "feat: Checks primitives + ValidationBuilder"
|
||||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||||
git -C ~/Desktop/scadaproj commit -m "feat: Checks primitives + ValidationBuilder"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -559,11 +563,11 @@ public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run — expect PASS**, then **Commit** (nested repo)
|
||||
**Step 4: Run — expect PASS**, then **Commit** (outer repo)
|
||||
```bash
|
||||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration commit -m "feat: OptionsValidatorBase<TOptions>"
|
||||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||||
git -C ~/Desktop/scadaproj commit -m "feat: OptionsValidatorBase<TOptions>"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -669,11 +673,11 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run — expect PASS**, then **Commit** (nested repo)
|
||||
**Step 4: Run — expect PASS**, then **Commit** (outer repo)
|
||||
```bash
|
||||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration commit -m "feat: AddValidatedOptions bind+validate+ValidateOnStart"
|
||||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||||
git -C ~/Desktop/scadaproj commit -m "feat: AddValidatedOptions bind+validate+ValidateOnStart"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -825,11 +829,11 @@ public sealed class ConfigPreflight
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run — expect PASS**, then **Commit** (nested repo)
|
||||
**Step 4: Run — expect PASS**, then **Commit** (outer repo)
|
||||
```bash
|
||||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration commit -m "feat: ConfigPreflight raw-config aggregator"
|
||||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||||
git -C ~/Desktop/scadaproj commit -m "feat: ConfigPreflight raw-config aggregator"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -862,8 +866,8 @@ Expected: all tests green; exactly one `.nupkg` at `0.1.0`.
|
||||
|
||||
**Step 4: Commit** (nested repo)
|
||||
```bash
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration add -A
|
||||
git -C ~/Desktop/scadaproj/ZB.MOM.WW.Configuration commit -m "docs: README + CLAUDE.md; verify 0.1.0 pack"
|
||||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||||
git -C ~/Desktop/scadaproj commit -m "docs: README + CLAUDE.md; verify 0.1.0 pack"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: components/configuration spec + shared-contract", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: components/configuration current-state x3 + GAPS + README", "status": "pending"},
|
||||
{"id": 3, "subject": "Task 3: scaffold nested repo, solution, library + test projects", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: Checks (internal) + ValidationBuilder", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: OptionsValidatorBase<TOptions>", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: ServiceCollectionExtensions.AddValidatedOptions", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 7, "subject": "Task 7: ConfigPreflight", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 8, "subject": "Task 8: README + dotnet pack -> verify single nupkg", "status": "pending", "blockedBy": [5, 6, 7]},
|
||||
{"id": 9, "subject": "Task 9: register in indexes (components/README, root CLAUDE.md, upcoming.md)", "status": "pending", "blockedBy": [1, 2, 8]}
|
||||
{"id": 1, "subject": "Task 1: components/configuration spec + shared-contract", "status": "completed"},
|
||||
{"id": 2, "subject": "Task 2: components/configuration current-state x3 + GAPS + README", "status": "completed"},
|
||||
{"id": 3, "subject": "Task 3: scaffold solution, library + test projects", "status": "completed"},
|
||||
{"id": 4, "subject": "Task 4: Checks (internal) + ValidationBuilder", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: OptionsValidatorBase<TOptions>", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: ServiceCollectionExtensions.AddValidatedOptions", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 7, "subject": "Task 7: ConfigPreflight", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 8, "subject": "Task 8: README + dotnet pack -> verify single nupkg", "status": "completed", "blockedBy": [5, 6, 7]},
|
||||
{"id": 9, "subject": "Task 9: register in indexes (components/README, root CLAUDE.md, upcoming.md)", "status": "completed", "blockedBy": [1, 2, 8]}
|
||||
],
|
||||
"lastUpdated": "2026-06-01"
|
||||
}
|
||||
|
||||
+1
-2
@@ -77,8 +77,7 @@ cross-repo interop checks, distinct from the others.
|
||||
family is being delivered as `ZB.MOM.WW.Telemetry.Serilog` by the health/observability normalization
|
||||
pass ([`components/health/`](components/health/)), not as a standalone `ZB.MOM.WW.Logging` lib —
|
||||
a separate Logging candidate is not expected.
|
||||
- **Config validation conventions:** all three use IOptions + `IValidateOptions` + `ValidateOnStart`;
|
||||
a shared validation base + startup-validation helper is reusable and pairs with the Auth options pattern.
|
||||
- ~~**Config validation conventions:**~~ **Done** — `ZB.MOM.WW.Configuration` built @ 0.1.0 (1 package, 27 tests): `OptionsValidatorBase` + `ValidationBuilder` primitives + `AddValidatedOptions` (`ValidateOnStart`) + pre-host `ConfigPreflight` (generalizes ScadaBridge's `StartupValidator`). Design: [`components/configuration/`](components/configuration/); implementation: [`../ZB.MOM.WW.Configuration/`](../ZB.MOM.WW.Configuration/). Adoption tracked in [`components/configuration/GAPS.md`](components/configuration/GAPS.md).
|
||||
|
||||
## Skip / defer
|
||||
- **Result/error primitives** — trivially shareable but low-stakes and bikeshed-prone.
|
||||
|
||||
Reference in New Issue
Block a user