From 80e4d592091ea2ac242e3a4859173ef1ecba14f3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:23:08 -0400 Subject: [PATCH 01/12] =?UTF-8?q?plan(config):=20correct=20git=20layout=20?= =?UTF-8?q?=E2=80=94=20library=20committed=20to=20outer=20repo,=20no=20nes?= =?UTF-8?q?ted=20.git?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling libs (Auth/Theme/Health/Telemetry) are tracked as regular files in the outer scadaproj repo, not separate git repos. Remove the git-init/nested-repo instructions; all commits target the outer repo on feat/zb-mom-ww-configuration. --- ...-zb-mom-ww-configuration-shared-library.md | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md index 7dabdc4..d741dae 100644 --- a/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md +++ b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md @@ -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`, `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 `" "`. **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. ``` -**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 : IValidateOptions" +git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration +git -C ~/Desktop/scadaproj commit -m "feat: OptionsValidatorBase" ``` --- @@ -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" ``` --- From a104372eacfb2f686810f085420d7ad0ec25ce3e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:25:26 -0400 Subject: [PATCH 02/12] chore: scaffold ZB.MOM.WW.Configuration solution --- ZB.MOM.WW.Configuration/.gitignore | 482 ++++++++++++++++++ ZB.MOM.WW.Configuration/Directory.Build.props | 10 + .../Directory.Packages.props | 19 + .../ZB.MOM.WW.Configuration.slnx | 8 + .../ZB.MOM.WW.Configuration.csproj | 17 + .../ZB.MOM.WW.Configuration.Tests.csproj | 19 + 6 files changed, 555 insertions(+) create mode 100644 ZB.MOM.WW.Configuration/.gitignore create mode 100644 ZB.MOM.WW.Configuration/Directory.Build.props create mode 100644 ZB.MOM.WW.Configuration/Directory.Packages.props create mode 100644 ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx create mode 100644 ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj create mode 100644 ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj diff --git a/ZB.MOM.WW.Configuration/.gitignore b/ZB.MOM.WW.Configuration/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/ZB.MOM.WW.Configuration/.gitignore @@ -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 diff --git a/ZB.MOM.WW.Configuration/Directory.Build.props b/ZB.MOM.WW.Configuration/Directory.Build.props new file mode 100644 index 0000000..b234d68 --- /dev/null +++ b/ZB.MOM.WW.Configuration/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + latest + 0.1.0 + true + + diff --git a/ZB.MOM.WW.Configuration/Directory.Packages.props b/ZB.MOM.WW.Configuration/Directory.Packages.props new file mode 100644 index 0000000..4a7d7ae --- /dev/null +++ b/ZB.MOM.WW.Configuration/Directory.Packages.props @@ -0,0 +1,19 @@ + + + true + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx b/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx new file mode 100644 index 0000000..f3278cf --- /dev/null +++ b/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj new file mode 100644 index 0000000..feb7233 --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj @@ -0,0 +1,17 @@ + + + true + ZB.MOM.WW.Configuration + ZB.MOM.WW + 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. + configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration + + + + + + + + diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj new file mode 100644 index 0000000..a4e7f02 --- /dev/null +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj @@ -0,0 +1,19 @@ + + + false + + + + + + + + + + + + + + + + From d18c121033818210ae71b38490babfa91ab86e46 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:28:19 -0400 Subject: [PATCH 03/12] feat: Checks primitives + ValidationBuilder --- .../src/ZB.MOM.WW.Configuration/Checks.cs | 40 ++++++++++ .../ValidationBuilder.cs | 56 ++++++++++++++ .../ValidationBuilderTests.cs | 74 +++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs create mode 100644 ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs create mode 100644 ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs new file mode 100644 index 0000000..1935f35 --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs @@ -0,0 +1,40 @@ +namespace ZB.MOM.WW.Configuration; + +/// +/// Internal rule primitives shared by (validates a bound options +/// object) and (validates raw IConfiguration). Each method +/// returns null when valid, or a formatted "<field> <reason>" message +/// otherwise. Centralizing them keeps wording identical across both front-ends. +/// +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; + + 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 + || !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 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(IReadOnlyCollection? value, int min, string field) => + value is null || value.Count < min + ? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})" + : null; +} diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs new file mode 100644 index 0000000..e221734 --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs @@ -0,0 +1,56 @@ +namespace ZB.MOM.WW.Configuration; + +/// +/// Accumulates validation failures for an options object. Passed by +/// into your Validate override; each primitive +/// both checks a value and appends a consistently-formatted message on failure. Use +/// / for custom or cross-field rules. +/// +public sealed class ValidationBuilder +{ + private readonly List _failures = []; + + /// The accumulated failure messages (empty when validation passed). + public IReadOnlyList Failures => _failures; + + /// True when no failures have been accumulated. + public bool IsValid => _failures.Count == 0; + + /// Records as a failure when is false. + public ValidationBuilder RequireThat(bool ok, string message) + { + if (!ok) _failures.Add(message); + return this; + } + + /// Unconditionally records as a failure. + public ValidationBuilder Add(string message) + { + _failures.Add(message); + return this; + } + + /// Requires a non-null, non-whitespace string. + public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field)); + + /// Requires a TCP port in 1-65535. + public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field)); + + /// Requires a 'host:port' endpoint with a valid port. + public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field)); + + /// Requires a strictly positive duration. + public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field)); + + /// Requires the value to be one of (case-insensitive). + public ValidationBuilder OneOf(string? value, IReadOnlyCollection allowed, string field) => AddIf(Checks.OneOf(value, allowed, field)); + + /// Requires a collection with at least items. + public ValidationBuilder MinCount(IReadOnlyCollection? 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; + } +} diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs new file mode 100644 index 0000000..e28a495 --- /dev/null +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs @@ -0,0 +1,74 @@ +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)] + 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 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); + } +} From 563cf44c605286e47dde82f9701e02318f97c46b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:29:46 -0400 Subject: [PATCH 04/12] feat: OptionsValidatorBase --- .../OptionsValidatorBase.cs | 30 +++++++++++++++ .../OptionsValidatorBaseTests.cs | 37 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/OptionsValidatorBase.cs create mode 100644 ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/OptionsValidatorBaseTests.cs diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/OptionsValidatorBase.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/OptionsValidatorBase.cs new file mode 100644 index 0000000..c2dc7eb --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/OptionsValidatorBase.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.Configuration; + +/// +/// Base class for implementations that removes the +/// failure-accumulation plumbing. Override and +/// use the supplied ; the base aggregates ALL failures and returns +/// only when none were recorded. +/// +/// The options type being validated. +public abstract class OptionsValidatorBase : IValidateOptions + where TOptions : class +{ + /// + 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); + } + + /// Records validation failures for on . + /// The accumulator to record failures on. + /// The options instance to validate. + protected abstract void Validate(ValidationBuilder builder, TOptions options); +} diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/OptionsValidatorBaseTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/OptionsValidatorBaseTests.cs new file mode 100644 index 0000000..1def4df --- /dev/null +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/OptionsValidatorBaseTests.cs @@ -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 + { + 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()); + } +} From e191893738c0c4559bc57803e5964939dec1e6fa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:31:14 -0400 Subject: [PATCH 05/12] feat: AddValidatedOptions bind+validate+ValidateOnStart --- .../ServiceCollectionExtensions.cs | 36 ++++++++++++++ .../AddValidatedOptionsTests.cs | 47 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs create mode 100644 ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..050b2fd --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.Configuration; + +/// DI extensions for binding-and-validating an options section in one call. +public static class ServiceCollectionExtensions +{ + /// + /// Binds to the configuration section at + /// , registers as its + /// , and enables ValidateOnStart so a bad + /// configuration fails fast at host startup rather than on first use. + /// + /// The options type to bind and validate. + /// The validator registered for . + /// The service collection. + /// The configuration to bind from. + /// The configuration section path (e.g. "ScadaBridge:Cluster"). + /// The for further chaining. + public static OptionsBuilder AddValidatedOptions( + this IServiceCollection services, IConfiguration configuration, string sectionPath) + where TOptions : class + where TValidator : class, IValidateOptions + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); + + services.AddSingleton, TValidator>(); + return services.AddOptions() + .Bind(configuration.GetSection(sectionPath)) + .ValidateOnStart(); + } +} diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs new file mode 100644 index 0000000..13d7d18 --- /dev/null +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs @@ -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 + { + 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 config) + { + var builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(config); + builder.Services.AddValidatedOptions(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(() => 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>().Value; + Assert.Equal(8080, opts.Port); + Assert.Equal("central", opts.Name); + await host.StopAsync(); + } +} From 8145d79dc6aeec462a90de50a4e901944bff1c96 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:32:44 -0400 Subject: [PATCH 06/12] feat: ConfigPreflight raw-config aggregator --- .../ConfigPreflight.cs | 75 +++++++++++++++++++ .../ConfigPreflightTests.cs | 49 ++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs create mode 100644 ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs new file mode 100644 index 0000000..0ebfff6 --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.Configuration; + +/// +/// Fluent aggregator for validating raw BEFORE the host/DI container +/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via +/// . For options that flow through DI, prefer +/// . +/// +public sealed class ConfigPreflight +{ + private readonly IConfiguration _configuration; + private readonly List _failures = []; + + private ConfigPreflight(IConfiguration configuration) => _configuration = configuration; + + /// Starts a preflight over . + public static ConfigPreflight For(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new ConfigPreflight(configuration); + } + + /// The accumulated failure messages (empty when valid). + public IReadOnlyList Failures => _failures; + + /// True when no failures have been accumulated. + public bool IsValid => _failures.Count == 0; + + /// Requires the value at to satisfy . + public ConfigPreflight Require(string key, Func predicate, string reason) + { + ArgumentNullException.ThrowIfNull(predicate); + if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}"); + return this; + } + + /// Requires a non-empty value at . + public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key)); + + /// Requires a valid integer TCP port (1-65535) at . + public ConfigPreflight RequirePort(string key) + { + var raw = _configuration[key]; + if (!int.TryParse(raw, out var port)) + { + _failures.Add($"{key} must be an integer port 1-65535 (was '{raw ?? "null"}')"); + return this; + } + return AddIf(Checks.Port(port, key)); + } + + /// Runs only when holds (role-conditional rules). + public ConfigPreflight When(bool condition, Action block) + { + ArgumentNullException.ThrowIfNull(block); + if (condition) block(this); + return this; + } + + /// Throws listing all failures when invalid; otherwise returns. + 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; + } +} diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs new file mode 100644 index 0000000..8b8428d --- /dev/null +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.Configuration.Tests; + +public sealed class ConfigPreflightTests +{ + private static IConfiguration Config(Dictionary 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 ThrowIfInvalid_throws_aggregated_message() + { + var cfg = Config(new() { ["Node:Name"] = "" }); + var ex = Assert.Throws(() => + 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 + } +} From 8d91a3021d171e87a26488c8169dcdf6b8bfae0b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:37:53 -0400 Subject: [PATCH 07/12] fix(config): centralize port wording, harden HostPort/key guards, doc null/singleton semantics, add tests --- .../src/ZB.MOM.WW.Configuration/Checks.cs | 14 ++++++++++++++ .../ZB.MOM.WW.Configuration/ConfigPreflight.cs | 16 ++++++++-------- .../ServiceCollectionExtensions.cs | 5 +++++ .../ZB.MOM.WW.Configuration/ValidationBuilder.cs | 6 +++++- .../ConfigPreflightTests.cs | 9 +++++++++ .../ValidationBuilderTests.cs | 10 ++++++++++ 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs index 1935f35..f9c1e6f 100644 --- a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs @@ -14,11 +14,25 @@ internal static class Checks internal static string? Port(int value, string field) => value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null; + /// + /// Validates a raw string as a TCP port (parse + range), returning null when valid. + /// Centralizes the port wording for callers that hold the raw config value. + /// + 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"}')"; + + /// + /// Validates a non-bracketed host:port endpoint (port 1-65535). Bracketed IPv6 + /// literals ([::1]:port) are out of scope and are rejected. + /// 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}')"; diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs index 0ebfff6..96a5b96 100644 --- a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs @@ -31,24 +31,24 @@ public sealed class ConfigPreflight /// Requires the value at to satisfy . public ConfigPreflight Require(string key, Func predicate, string reason) { + ArgumentException.ThrowIfNullOrWhiteSpace(key); ArgumentNullException.ThrowIfNull(predicate); if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}"); return this; } /// Requires a non-empty value at . - public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key)); + public ConfigPreflight RequireValue(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + return AddIf(Checks.Required(_configuration[key], key)); + } /// Requires a valid integer TCP port (1-65535) at . public ConfigPreflight RequirePort(string key) { - var raw = _configuration[key]; - if (!int.TryParse(raw, out var port)) - { - _failures.Add($"{key} must be an integer port 1-65535 (was '{raw ?? "null"}')"); - return this; - } - return AddIf(Checks.Port(port, key)); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + return AddIf(Checks.PortValue(_configuration[key], key)); } /// Runs only when holds (role-conditional rules). diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs index 050b2fd..7456f72 100644 --- a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs @@ -19,6 +19,11 @@ public static class ServiceCollectionExtensions /// The configuration to bind from. /// The configuration section path (e.g. "ScadaBridge:Cluster"). /// The for further chaining. + /// + /// 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. + /// public static OptionsBuilder AddValidatedOptions( this IServiceCollection services, IConfiguration configuration, string sectionPath) where TOptions : class diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs index e221734..7055853 100644 --- a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs @@ -42,7 +42,11 @@ public sealed class ValidationBuilder /// Requires a strictly positive duration. public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field)); - /// Requires the value to be one of (case-insensitive). + /// + /// Requires the value to be one of (case-insensitive). A + /// null value fails this rule; call first if the field may be + /// absent and you want a "required" message instead of a "must be one of" message. + /// public ValidationBuilder OneOf(string? value, IReadOnlyCollection allowed, string field) => AddIf(Checks.OneOf(value, allowed, field)); /// Requires a collection with at least items. diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs index 8b8428d..a8bdca3 100644 --- a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs @@ -30,6 +30,15 @@ public sealed class ConfigPreflightTests 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() { diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs index e28a495..96f22af 100644 --- a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs @@ -33,6 +33,7 @@ public sealed class ValidationBuilderTests [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(); @@ -56,6 +57,15 @@ public sealed class ValidationBuilderTests 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() { From b754873a44ef74aaf387e4b97563e25978e63dcf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:40:20 -0400 Subject: [PATCH 08/12] docs: README + CLAUDE.md; verify 0.1.0 pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZB.MOM.WW.Configuration — README with purpose, what's-in-the-box, three usage snippets (validator subclass, DI wiring, ConfigPreflight), build/test/pack instructions, and dependency note. CLAUDE.md with one-screen orientation: package table, commands, source layout, and component-normalization status note. 27 tests pass; dotnet pack produces exactly one nupkg (0.1.0). --- ZB.MOM.WW.Configuration/CLAUDE.md | 76 ++++++++++++++++++++ ZB.MOM.WW.Configuration/README.md | 112 ++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 ZB.MOM.WW.Configuration/CLAUDE.md create mode 100644 ZB.MOM.WW.Configuration/README.md diff --git a/ZB.MOM.WW.Configuration/CLAUDE.md b/ZB.MOM.WW.Configuration/CLAUDE.md new file mode 100644 index 0000000..818b340 --- /dev/null +++ b/ZB.MOM.WW.Configuration/CLAUDE.md @@ -0,0 +1,76 @@ +# 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` (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 +│ ├── 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) diff --git a/ZB.MOM.WW.Configuration/README.md b/ZB.MOM.WW.Configuration/README.md new file mode 100644 index 0000000..b2eccf6 --- /dev/null +++ b/ZB.MOM.WW.Configuration/README.md @@ -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` | Abstract `IValidateOptions`. 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(IConfiguration config, string sectionPath)` — binds the section, registers the validator, and calls `ValidateOnStart()` in a single extension method. Returns `OptionsBuilder`. | +| `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 +{ + 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( + 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) From 46c4bfae311c7db680f6a87ce64ee122ef894b9c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:48:49 -0400 Subject: [PATCH 09/12] docs(config): components/configuration normalization (spec, shared-contract, current-state x3, GAPS, README) --- components/configuration/GAPS.md | 139 ++++++++++++ components/configuration/README.md | 99 +++++++++ .../current-state/mxaccessgw/CURRENT-STATE.md | 122 +++++++++++ .../current-state/otopcua/CURRENT-STATE.md | 98 +++++++++ .../scadabridge/CURRENT-STATE.md | 191 +++++++++++++++++ .../ZB.MOM.WW.Configuration.md | 183 ++++++++++++++++ components/configuration/spec/SPEC.md | 201 ++++++++++++++++++ 7 files changed, 1033 insertions(+) create mode 100644 components/configuration/GAPS.md create mode 100644 components/configuration/README.md create mode 100644 components/configuration/current-state/mxaccessgw/CURRENT-STATE.md create mode 100644 components/configuration/current-state/otopcua/CURRENT-STATE.md create mode 100644 components/configuration/current-state/scadabridge/CURRENT-STATE.md create mode 100644 components/configuration/shared-contract/ZB.MOM.WW.Configuration.md create mode 100644 components/configuration/spec/SPEC.md diff --git a/components/configuration/GAPS.md b/components/configuration/GAPS.md new file mode 100644 index 0000000..d567354 --- /dev/null +++ b/components/configuration/GAPS.md @@ -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` 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`, the `Count == 0 ? Success : Fail` tail, and in MxGateway the +`AddIfBlank`/`AddIfNotPositive` helpers). OtOpcUa has **no validators at all**. + +→ **Gap B1:** MxGateway: `GatewayOptionsValidator` → `OptionsValidatorBase`. +→ **Gap B2:** ScadaBridge: four `*OptionsValidator` → `OptionsValidatorBase`. +→ **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`) and ScadaBridge's four module extensions all spell out exactly +what `AddValidatedOptions(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 - "`, 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 - "`), 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. diff --git a/components/configuration/README.md b/components/configuration/README.md new file mode 100644 index 0000000..73ced6a --- /dev/null +++ b/components/configuration/README.md @@ -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` 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` | Not started (follow-on) | +| **ScadaBridge** | 🟡 four `*OptionsValidator` (hand-rolled) | ✅ `StartupValidator` (raw config, pre-Akka) | 🟡 manual `List` ×4 | Not started (follow-on; heaviest) | + +See each project's [`current-state//CURRENT-STATE.md`](current-state/) for the +code-verified detail and its adoption plan. + +## Normalized vs. left per-project + +**Normalized (the shared target):** + +- `OptionsValidatorBase` — abstract `IValidateOptions`; 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 + `" "` wording via the internal `Checks` seam. +- `AddValidatedOptions(IConfiguration, sectionPath)` — bind + register + validator + `ValidateOnStart` in one DI call; returns `OptionsBuilder`. +- `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`); `" "` 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`, `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 +``` diff --git a/components/configuration/current-state/mxaccessgw/CURRENT-STATE.md b/components/configuration/current-state/mxaccessgw/CURRENT-STATE.md new file mode 100644 index 0000000..89726f8 --- /dev/null +++ b/components/configuration/current-state/mxaccessgw/CURRENT-STATE.md @@ -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` 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` + +`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs`: +- `:6` — `public sealed class GatewayOptionsValidator : IValidateOptions` — + implements the interface directly (no shared base). +- `:17–34` — `Validate(string? name, GatewayOptions options)`: creates `List 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 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:
: ..."`) — 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().BindConfiguration(GatewayOptions.SectionName).ValidateOnStart();` + - `:17` — `services.AddSingleton, GatewayOptionsValidator>();` + - `:18` — also registers `IGatewayConfigurationProvider` (a separate concern; stays). + +Lines `:12–17` are exactly the `bind + register-validator + ValidateOnStart` triple that +`AddValidatedOptions` 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` (~360 LOC, 9 sub-validators) | → `OptionsValidatorBase` | +| Failure accumulation | private `List 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`) | → `AddValidatedOptions` | +| 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` → + `GatewayOptionsValidator : OptionsValidatorBase` + (`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 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`. 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` + pair with: + ```csharp + services.AddValidatedOptions( + 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:
:"` 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. diff --git a/components/configuration/current-state/otopcua/CURRENT-STATE.md b/components/configuration/current-state/otopcua/CURRENT-STATE.md new file mode 100644 index 0000000..f686d6f --- /dev/null +++ b/components/configuration/current-state/otopcua/CURRENT-STATE.md @@ -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().Bind(builder.Configuration.GetSection("Ldap"));` + Bound, **not** validated — no `ValidateOnStart()`, no registered `IValidateOptions`. + 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 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 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` 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().Bind(...)` with + `AddValidatedOptions(builder.Configuration, "Ldap")`. +- For `OpcUa`: if any field has a fail-fast invariant (e.g. a required endpoint or a port), add an + `OptionsValidatorBase` 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. diff --git a/components/configuration/current-state/scadabridge/CURRENT-STATE.md b/components/configuration/current-state/scadabridge/CURRENT-STATE.md new file mode 100644 index 0000000..7a5d7d5 --- /dev/null +++ b/components/configuration/current-state/scadabridge/CURRENT-STATE.md @@ -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`** (Cluster, Security, + HealthMonitoring, AuditLog), each open-coding the same `List` 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 failures`, a run of `if (...) failures.Add(...)`, +and `failures.Count > 0 ? Fail(failures) : Success` (order varies). They are registered via +`TryAddEnumerable(ServiceDescriptor.Singleton, ...>())` so a misconfigured +section throws `OptionsValidationException` (with `ValidateOnStart`) or on first `IOptions` resolve. + +### `ClusterOptionsValidator` + +`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs`: +- `:13` — `public sealed class ClusterOptionsValidator : IValidateOptions`. +- `:28` — `var failures = new List();`. +- `: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, ClusterOptionsValidator>())`. + +### `SecurityOptionsValidator` + +`src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs`: +- `:32` — `public sealed class SecurityOptionsValidator : IValidateOptions`. +- `:48` — `var failures = new List();`; `: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().ValidateOnStart()` + `TryAddEnumerable(...Singleton, SecurityOptionsValidator>())`. + +### `HealthMonitoringOptionsValidator` + +`src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs`: +- `:17` — `public sealed class HealthMonitoringOptionsValidator : IValidateOptions`. +- `:26` — `var failures = new List();`; `: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, 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`. +- `:35` — `var failures = new List();`; `: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().Bind(config.GetSection(ConfigSectionName)).ValidateOnStart()` + + `AddSingleton, 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();`. 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 `" "` (`StartupValidator` open-codes them; `ConfigPreflight` produces +them via the shared `Checks` primitives for the standardized rules — `RequireValue` → `" is +required"`, `RequirePort` → `" must be between 1 and 65535 (was '')"`). 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` | → `OptionsValidatorBase` | +| Security validator | `SecurityOptionsValidator : IValidateOptions` | → `OptionsValidatorBase` | +| Health validator | `HealthMonitoringOptionsValidator : IValidateOptions` | → `OptionsValidatorBase` | +| Audit validator | `AuditLogOptionsValidator : IValidateOptions` | → `OptionsValidatorBase` | +| Failure accumulation (×4) | private `List` + `Count`-based tail in each | → owned by base + `ValidationBuilder` | +| DI wiring (×4) | per-module `TryAddEnumerable`/`AddSingleton` + `AddOptions().Bind().ValidateOnStart()` | → `AddValidatedOptions` | +| 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` to `: OptionsValidatorBase`, replace + `Validate(string?, T)` with `protected override void Validate(ValidationBuilder v, T o)`, delete + the `List 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(configuration, "
")` 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. diff --git a/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md b/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md new file mode 100644 index 0000000..1a33ae2 --- /dev/null +++ b/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md @@ -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`, `ValidateOptionsResult`, `OptionsBuilder` | +| `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` + +```csharp +namespace ZB.MOM.WW.Configuration; + +/// Base class for IValidateOptions 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 : IValidateOptions + 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 " " message on failure. +public sealed class ValidationBuilder +{ + public IReadOnlyList 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 allowed, string field); + public ValidationBuilder MinCount(IReadOnlyCollection? 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 (singleton), and enables ValidateOnStart so a bad + /// configuration fails fast at host start. Returns the OptionsBuilder for chaining. + public static OptionsBuilder AddValidatedOptions( + this IServiceCollection services, IConfiguration configuration, string sectionPath) + where TOptions : class + where TValidator : class, IValidateOptions; +} +``` + +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 Failures { get; } // accumulated (empty when valid) + public bool IsValid { get; } + + public ConfigPreflight Require(string key, Func predicate, string 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 block); // role-conditional rules + + /// Throws InvalidOperationException listing all failures when invalid; otherwise returns. + /// Message envelope (byte-compatible with ScadaBridge StartupValidator): + /// "Configuration validation failed:\n - \n - " + 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 " " 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 allowed, string field); + internal static string? MinCount(IReadOnlyCollection? 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` + `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. diff --git a/components/configuration/spec/SPEC.md b/components/configuration/spec/SPEC.md new file mode 100644 index 0000000..0411d9f --- /dev/null +++ b/components/configuration/spec/SPEC.md @@ -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` failure-accumulation convention.** Every app hand-rolls a + `List failures`, a pile of `if (...) failures.Add(...)`, and the + `failures.Count == 0 ? Success : Fail(failures)` tail. That plumbing becomes + `OptionsValidatorBase`: 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(IConfiguration, sectionPath)`** — one DI call that + binds the section, registers the validator as the options' `IValidateOptions`, and + enables `ValidateOnStart()`. Replaces the per-module `AddOptions().Bind(...).ValidateOnStart()` + + `AddSingleton, ...>()` 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 `" "`** 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` + +The headline plumbing fix. Today each validator re-implements: the `Validate(string?, TOptions)` +signature, a local `List`, 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 +{ + 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.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 `" "` message on failure; escape hatches cover +the rest: + +| Primitive | Checks | Failure wording (from `Checks`) | +|---|---|---| +| `Required(value, field)` | non-null, non-whitespace string | `" is required"` | +| `Port(value, field)` | int in 1–65535 | `" must be between 1 and 65535 (was )"` | +| `HostPort(value, field)` | `host:port` with port 1–65535 | `" must be 'host:port' with port 1-65535 (was '')"` | +| `PositiveTimeSpan(value, field)` | `> TimeSpan.Zero` | `" must be a positive duration (was )"` | +| `OneOf(value, allowed, field)` | case-insensitive membership | `" must be one of [] (was '')"` | +| `MinCount(value, min, field)` | collection ≥ `min` items | `" must contain at least item(s) (had )"` | +| `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( + builder.Configuration, "ScadaBridge:Cluster"); +``` + +Binds `ScadaBridge:Cluster` → `ClusterOptions`, registers `ClusterOptionsValidator` as a +singleton `IValidateOptions`, and calls `ValidateOnStart()`. Returns the +`OptionsBuilder` for further chaining (e.g. `.PostConfigure(...)`). This collapses the +three-line idiom every module repeats (`AddOptions().Bind(...).ValidateOnStart()` + +`AddSingleton, ...>()`) 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: + - + - +``` + +> **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 `" - "` 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` (~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` (delete the `List`/tail/helpers; keep the domain rules); `AddGatewayConfiguration` → `AddValidatedOptions`. Domain rules unchanged. | +| **ScadaBridge** | **Heaviest.** Four per-module `*OptionsValidator : IValidateOptions` (Cluster / Security / HealthMonitoring / AuditLog) each with their own `List` 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`; 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` and records failures on the supplied `ValidationBuilder` (no +private `List` plumbing, no early return); (b) every bind-and-validate registration goes +through `AddValidatedOptions(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. From 3fa77b70fc7d535994fffeffa4ae608ba4d72530 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:51:22 -0400 Subject: [PATCH 10/12] docs: register ZB.MOM.WW.Configuration in indexes --- CLAUDE.md | 27 ++++++++++++++++++++++++--- components/README.md | 1 + upcoming.md | 3 +-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5fa1c12..bc4ee67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` failure-accumulation base (`OptionsValidatorBase`) + +reusable rule primitives (`ValidationBuilder`: port / host:port / required / positive-duration / one-of / +min-count) + `AddValidatedOptions()` (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 diff --git a/components/README.md b/components/README.md index b8103bd..4966f13 100644 --- a/components/README.md +++ b/components/README.md @@ -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`. diff --git a/upcoming.md b/upcoming.md index 3d8ee86..746f8b9 100644 --- a/upcoming.md +++ b/upcoming.md @@ -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. From a29f226a70444bb865a70f8f721ad7be41f309fd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:55:47 -0400 Subject: [PATCH 11/12] docs: list Checks.cs in library CLAUDE.md src tree --- ZB.MOM.WW.Configuration/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ZB.MOM.WW.Configuration/CLAUDE.md b/ZB.MOM.WW.Configuration/CLAUDE.md index 818b340..aa56e2b 100644 --- a/ZB.MOM.WW.Configuration/CLAUDE.md +++ b/ZB.MOM.WW.Configuration/CLAUDE.md @@ -55,6 +55,7 @@ ZB.MOM.WW.Configuration/ │ └── ZB.MOM.WW.Configuration/ # library project │ ├── OptionsValidatorBase.cs │ ├── ValidationBuilder.cs +│ ├── Checks.cs # internal shared rule wording │ ├── ServiceCollectionExtensions.cs │ └── ConfigPreflight.cs └── tests/ From 69fb6cb0774942fb0bb6ba109e224ec5395ed41c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:56:01 -0400 Subject: [PATCH 12/12] chore: mark configuration plan tasks complete --- ...-configuration-shared-library.md.tasks.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.tasks.json b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.tasks.json index 2a1fad6..4caaa3d 100644 --- a/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.tasks.json +++ b/docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.tasks.json @@ -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", "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", "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" }