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.
37 KiB
ZB.MOM.WW.Configuration Shared Library Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Author the components/configuration/ normalization docs and build the
ZB.MOM.WW.Configuration shared library (1 NuGet package) that gives the fleet one
startup-options-validation toolkit — a failure-accumulating IValidateOptions<T> base, reusable
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 self-contained solution directory committed into the outer scadaproj
repo at ~/Desktop/scadaproj/ZB.MOM.WW.Configuration (same layout as the sibling
ZB.MOM.WW.Telemetry/Health/Auth/Theme — regular tracked files, not a submodule and
not a separate .git; build output bin//obj//artifacts/ is gitignored). .NET 10, one
library project ZB.MOM.WW.Configuration with four public types
(OptionsValidatorBase<TOptions>, ValidationBuilder, ServiceCollectionExtensions,
ConfigPreflight) over one internal Checks helper that keeps rule wording identical across the
two front-ends (options-object validation vs raw-IConfiguration preflight). Scope is startup
options validation only — OtOpcUa's runtime draft/snapshot validation stays per-project. No
app is modified in this pass; adoption is a tracked GAPS.md follow-on, matching Auth/Health/Theme.
Tech Stack: .NET 10, C#; xUnit + coverlet; central package management; .slnx; Version
0.1.0. Library deps are minimal and framework-aligned:
Microsoft.Extensions.Options (carries IValidateOptions, OptionsBuilder, AddOptions,
ValidateOnStart — verified in net10's Microsoft.Extensions.Options assembly),
Microsoft.Extensions.Options.ConfigurationExtensions (Bind),
Microsoft.Extensions.Configuration.Abstractions,
Microsoft.Extensions.DependencyInjection.Abstractions — all already referenced by all three
apps, which is why this is a single package (no Akka/EF/Serilog-style heavy dep to isolate).
Version note: Pin the Microsoft.Extensions.* packages at 10.0.0 (the floor that matches
the installed net10 shared framework); the executor may bump to the latest restorable 10.0.x
patch. Consumers' own central package management governs the final version at adoption.
Design summary (approved via brainstorming)
| Decision | Choice | Rationale |
|---|---|---|
| Deliverable | Full pass (normalization docs + built library) | Match Auth/Theme/Health/Telemetry |
| Scope | Startup options validation only | Genuinely common across all 3; YAGNI |
| Package name | ZB.MOM.WW.Configuration |
Full-word style; aligns with Microsoft.Extensions.Configuration/Options |
| API style | Approach A — lightweight base + rule primitives + DI/startup helpers | Mirrors what all 3 already do; no new fleet-wide dependency; single package |
Out of scope (YAGNI): runtime draft/snapshot validation (stays in OtOpcUa's DraftValidator);
any FluentValidation/DataAnnotations dependency; editing the three apps' code.
Public API (namespace ZB.MOM.WW.Configuration; everything else internal):
OptionsValidatorBase<TOptions>— abstractIValidateOptions<TOptions>; override oneValidate(ValidationBuilder, TOptions); base aggregates ALL failures,Successonly when clean.ValidationBuilder— failure accumulator with primitives (Required,Port,HostPort,PositiveTimeSpan,OneOf,MinCount) +RequireThat/Addescape hatch for cross-field rules.ServiceCollectionExtensions.AddValidatedOptions<TOptions,TValidator>(config, sectionPath)— bind + register validator +ValidateOnStart(); returnsOptionsBuilder<TOptions>.ConfigPreflight— fluent aggregator over rawIConfigurationfor pre-host checks (For/Require/RequireValue/RequirePort/When/ThrowIfInvalid).
Error-handling contract: accumulate (never fail-fast-on-first); options validators surface via
ValidateOnStart() → host throws OptionsValidationException; ConfigPreflight.ThrowIfInvalid()
throws one InvalidOperationException with a "Configuration validation failed:\n - …" body —
byte-compatible with ScadaBridge's current StartupValidator so its swap is a drop-in. Message
format is "<field> <reason>".
Conventions for every task: TDD — failing test first, minimal impl, green, commit. File-scoped
namespaces, sealed by default, XML doc comments on public members (match the sibling libs).
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) - MxAccessGateway:
~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/{GatewayOptionsValidator,GatewayConfigurationServiceCollectionExtensions}.cs - ScadaBridge: per-module
*OptionsValidator.cs(ClusterInfrastructure,Security,HealthMonitoring,AuditLog) +~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs+ each module'sServiceCollectionExtensions.cs - Design + conventions to mirror:
~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/(props,.slnx, csproj, code/test style)
Phase 0 — Normalization docs (spec drives the API)
Task 1: components/configuration spec + shared-contract
Classification: small Estimated implement time: ~5 min Parallelizable with: Task 2
Files:
- Create:
components/configuration/spec/SPEC.md - Create:
components/configuration/shared-contract/ZB.MOM.WW.Configuration.md
Steps:
spec/SPEC.md— Scope section: normalized = theIValidateOptions<T>failure-accumulation convention, the reusable rule primitives, theAddValidatedOptionsbind+validate+ValidateOnStartwiring, and the pre-hostConfigPreflightaggregator (generalizes ScadaBridge'sStartupValidator). NOT normalized = each app's options classes + their domain rules (cluster/security/gateway), and OtOpcUa's runtime draft/snapshot validation. Document the error-handling contract (accumulate; two surfacing paths;"<field> <reason>"message format).shared-contract/ZB.MOM.WW.Configuration.md— paper API of the single package: the four public types with signatures (copy from the Design summary above), the internalChecksseam, and the consumer matrix (all three apps; ScadaBridge heaviest —StartupValidator→ConfigPreflight).
Acceptance: both files exist; SPEC explicitly lists normalized vs per-project; shared-contract signatures match the code built in Phase 1.
Commit (outer repo): git -C ~/Desktop/scadaproj add components/configuration && git -C ~/Desktop/scadaproj commit -m "docs(config): normalization spec + shared-contract"
Task 2: components/configuration current-state ×3 + GAPS + README
Classification: small Estimated implement time: ~5 min Parallelizable with: Task 1
Files:
- Create:
components/configuration/current-state/otopcua/CURRENT-STATE.md - Create:
components/configuration/current-state/mxaccessgw/CURRENT-STATE.md - Create:
components/configuration/current-state/scadabridge/CURRENT-STATE.md - Create:
components/configuration/GAPS.md - Create:
components/configuration/README.md
Steps:
- For each app, read the validators under the Source references and write
CURRENT-STATE.mdwithfile:linerefs describing how it validates config today + an Adoption plan (what it deletes/replaces to reach the spec; what stays bespoke). Emphasize ScadaBridge'sStartupValidator(raw-config, pre-Akka) →ConfigPreflight, and that OtOpcUa'sDraftValidatorstays put. GAPS.md— per-project divergence vs SPEC + a prioritized extraction/adoption backlog (each app adopts the base +AddValidatedOptions; ScadaBridge additionally swapsStartupValidator).README.md— overview + per-project status table linking the docs above (mirrorcomponents/observability/README.md).
Acceptance: all five files exist; each current-state has real file:line refs and an Adoption
plan; README status table links resolve.
Commit (outer repo): git -C ~/Desktop/scadaproj add components/configuration && git -C ~/Desktop/scadaproj commit -m "docs(config): current-state x3 + GAPS + README"
Phase 1 — Build the library (TDD)
Task 3: Scaffold nested repo, solution, library + test projects
Classification: small Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
ZB.MOM.WW.Configuration/.gitignore(copy from~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/.gitignore) - Create:
ZB.MOM.WW.Configuration/Directory.Build.props - Create:
ZB.MOM.WW.Configuration/Directory.Packages.props - Create:
ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx - Create:
ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj - Create:
ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj
Step 1: Directory.Build.props (identical to Telemetry's)
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
Step 2: Directory.Packages.props
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Library -->
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<!-- Test only -->
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>
Step 3: src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Configuration</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Startup configuration-validation toolkit for the ZB.MOM.WW SCADA family: a failure-accumulating IValidateOptions base, reusable rule primitives (port, host:port, required, positive-duration, one-of, min-count), a bind+validate+ValidateOnStart DI helper, and a pre-host ConfigPreflight aggregator for raw IConfiguration. Extracts the validation plumbing the apps share; domain rules stay per-project.</Description>
<PackageTags>configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww</PackageTags>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>
Step 4: tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Configuration\ZB.MOM.WW.Configuration.csproj" />
</ItemGroup>
</Project>
Step 5: ZB.MOM.WW.Configuration.slnx
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj" />
</Folder>
</Solution>
Step 6: verify restore/build (NO git init — the library is tracked inside the outer repo)
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 (outer repo, branch feat/zb-mom-ww-configuration)
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "chore: scaffold ZB.MOM.WW.Configuration solution"
Task 4: Checks (internal) + ValidationBuilder
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (Tasks 5–7 depend on it)
Files:
- Create:
src/ZB.MOM.WW.Configuration/Checks.cs - Create:
src/ZB.MOM.WW.Configuration/ValidationBuilder.cs - Test:
tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs
Step 1: Write the failing tests (ValidationBuilderTests.cs)
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);
}
}
Step 2: Run — expect FAIL (Checks/ValidationBuilder not defined)
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
Step 3: Implement Checks.cs
namespace ZB.MOM.WW.Configuration;
/// <summary>
/// Internal rule primitives shared by <see cref="ValidationBuilder"/> (validates a bound options
/// object) and <see cref="ConfigPreflight"/> (validates raw <c>IConfiguration</c>). Each method
/// returns <c>null</c> when valid, or a formatted <c>"<field> <reason>"</c> message
/// otherwise. Centralizing them keeps wording identical across both front-ends.
/// </summary>
internal static class Checks
{
internal static string? Required(string? value, string field) =>
string.IsNullOrWhiteSpace(value) ? $"{field} is required" : null;
internal static string? Port(int value, string field) =>
value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null;
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<string> allowed, string field) =>
value is not null && allowed.Contains(value, StringComparer.OrdinalIgnoreCase)
? null
: $"{field} must be one of [{string.Join(", ", allowed)}] (was '{value ?? "null"}')";
internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) =>
value is null || value.Count < min
? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})"
: null;
}
Step 4: Implement ValidationBuilder.cs
namespace ZB.MOM.WW.Configuration;
/// <summary>
/// Accumulates validation failures for an options object. Passed by
/// <see cref="OptionsValidatorBase{TOptions}"/> into your <c>Validate</c> override; each primitive
/// both checks a value and appends a consistently-formatted message on failure. Use
/// <see cref="RequireThat"/>/<see cref="Add"/> for custom or cross-field rules.
/// </summary>
public sealed class ValidationBuilder
{
private readonly List<string> _failures = [];
/// <summary>The accumulated failure messages (empty when validation passed).</summary>
public IReadOnlyList<string> Failures => _failures;
/// <summary>True when no failures have been accumulated.</summary>
public bool IsValid => _failures.Count == 0;
/// <summary>Records <paramref name="message"/> as a failure when <paramref name="ok"/> is false.</summary>
public ValidationBuilder RequireThat(bool ok, string message)
{
if (!ok) _failures.Add(message);
return this;
}
/// <summary>Unconditionally records <paramref name="message"/> as a failure.</summary>
public ValidationBuilder Add(string message)
{
_failures.Add(message);
return this;
}
/// <summary>Requires a non-null, non-whitespace string.</summary>
public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field));
/// <summary>Requires a TCP port in 1-65535.</summary>
public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field));
/// <summary>Requires a 'host:port' endpoint with a valid port.</summary>
public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field));
/// <summary>Requires a strictly positive duration.</summary>
public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field));
/// <summary>Requires the value to be one of <paramref name="allowed"/> (case-insensitive).</summary>
public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field) => AddIf(Checks.OneOf(value, allowed, field));
/// <summary>Requires a collection with at least <paramref name="min"/> items.</summary>
public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) => AddIf(Checks.MinCount(value, min, field));
private ValidationBuilder AddIf(string? message)
{
if (message is not null) _failures.Add(message);
return this;
}
}
Step 5: Run — expect PASS
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
Step 6: Commit (nested repo)
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: Checks primitives + ValidationBuilder"
Task 5: OptionsValidatorBase<TOptions>
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 6, Task 7
Files:
- Create:
src/ZB.MOM.WW.Configuration/OptionsValidatorBase.cs - Test:
tests/ZB.MOM.WW.Configuration.Tests/OptionsValidatorBaseTests.cs
Step 1: Write the failing tests
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.Configuration.Tests;
public sealed class OptionsValidatorBaseTests
{
private sealed class SampleOptions
{
public int Port { get; set; }
public string? Name { get; set; }
}
private sealed class SampleValidator : OptionsValidatorBase<SampleOptions>
{
protected override void Validate(ValidationBuilder v, SampleOptions o)
{
v.Port(o.Port, "Sample:Port");
v.Required(o.Name, "Sample:Name");
}
}
[Fact]
public void Success_when_clean()
{
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 8080, Name = "ok" });
Assert.True(r.Succeeded);
}
[Fact]
public void Fails_and_reports_all_failures()
{
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 0, Name = "" });
Assert.True(r.Failed);
Assert.Equal(2, r.Failures!.Count());
}
}
Step 2: Run — expect FAIL
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter OptionsValidatorBaseTests
Step 3: Implement OptionsValidatorBase.cs
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.Configuration;
/// <summary>
/// Base class for <see cref="IValidateOptions{TOptions}"/> implementations that removes the
/// failure-accumulation plumbing. Override <see cref="Validate(ValidationBuilder, TOptions)"/> and
/// use the supplied <see cref="ValidationBuilder"/>; the base aggregates ALL failures and returns
/// <see cref="ValidateOptionsResult.Success"/> only when none were recorded.
/// </summary>
/// <typeparam name="TOptions">The options type being validated.</typeparam>
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, TOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var builder = new ValidationBuilder();
Validate(builder, options);
return builder.IsValid
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(builder.Failures);
}
/// <summary>Records validation failures for <paramref name="options"/> on <paramref name="builder"/>.</summary>
/// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">The options instance to validate.</param>
protected abstract void Validate(ValidationBuilder builder, TOptions options);
}
Step 4: Run — expect PASS, then Commit (outer repo)
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: OptionsValidatorBase<TOptions>"
Task 6: ServiceCollectionExtensions.AddValidatedOptions
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5, Task 7
Files:
- Create:
src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs - Test:
tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs
Step 1: Write the failing tests (drives ValidateOnStart via a real host start)
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.Configuration.Tests;
public sealed class AddValidatedOptionsTests
{
private sealed class NodeOptions { public int Port { get; set; } public string? Name { get; set; } }
private sealed class NodeValidator : OptionsValidatorBase<NodeOptions>
{
protected override void Validate(ValidationBuilder v, NodeOptions o)
{
v.Port(o.Port, "Node:Port");
v.Required(o.Name, "Node:Name");
}
}
private static IHost BuildHost(Dictionary<string, string?> config)
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(config);
builder.Services.AddValidatedOptions<NodeOptions, NodeValidator>(builder.Configuration, "Node");
return builder.Build();
}
[Fact]
public async Task Bad_config_throws_at_startup()
{
using var host = BuildHost(new() { ["Node:Port"] = "0", ["Node:Name"] = "" });
await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
}
[Fact]
public async Task Good_config_starts_and_binds()
{
using var host = BuildHost(new() { ["Node:Port"] = "8080", ["Node:Name"] = "central" });
await host.StartAsync();
var opts = host.Services.GetRequiredService<IOptions<NodeOptions>>().Value;
Assert.Equal(8080, opts.Port);
Assert.Equal("central", opts.Name);
await host.StopAsync();
}
}
Step 2: Run — expect FAIL
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter AddValidatedOptionsTests
Step 3: Implement ServiceCollectionExtensions.cs
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.Configuration;
/// <summary>DI extensions for binding-and-validating an options section in one call.</summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Binds <typeparamref name="TOptions"/> to the configuration section at
/// <paramref name="sectionPath"/>, registers <typeparamref name="TValidator"/> as its
/// <see cref="IValidateOptions{TOptions}"/>, and enables <c>ValidateOnStart</c> so a bad
/// configuration fails fast at host startup rather than on first use.
/// </summary>
/// <returns>The <see cref="OptionsBuilder{TOptions}"/> for further chaining.</returns>
public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
this IServiceCollection services, IConfiguration configuration, string sectionPath)
where TOptions : class
where TValidator : class, IValidateOptions<TOptions>
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
services.AddSingleton<IValidateOptions<TOptions>, TValidator>();
return services.AddOptions<TOptions>()
.Bind(configuration.GetSection(sectionPath))
.ValidateOnStart();
}
}
Step 4: Run — expect PASS, then Commit (outer repo)
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: AddValidatedOptions bind+validate+ValidateOnStart"
Task 7: ConfigPreflight
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5, Task 6
Files:
- Create:
src/ZB.MOM.WW.Configuration/ConfigPreflight.cs - Test:
tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs
Step 1: Write the failing tests
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.Configuration.Tests;
public sealed class ConfigPreflightTests
{
private static IConfiguration Config(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
[Fact]
public void Aggregates_all_failures()
{
var cfg = Config(new() { ["Node:Role"] = "Bogus", ["Node:RemotingPort"] = "0" });
var pf = ConfigPreflight.For(cfg)
.Require("Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
.RequirePort("Node:RemotingPort");
Assert.False(pf.IsValid);
Assert.Equal(2, pf.Failures.Count);
}
[Fact]
public void When_runs_block_only_if_condition_true()
{
var cfg = Config(new() { ["Node:Role"] = "Site" });
var pf = ConfigPreflight.For(cfg)
.When(cfg["Node:Role"] == "Site",
p => p.RequireValue("Node:SiteId"));
Assert.False(pf.IsValid); // SiteId missing
Assert.Contains(pf.Failures, f => f.Contains("Node:SiteId"));
}
[Fact]
public void ThrowIfInvalid_throws_aggregated_message()
{
var cfg = Config(new() { ["Node:Name"] = "" });
var ex = Assert.Throws<InvalidOperationException>(() =>
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid());
Assert.StartsWith("Configuration validation failed:", ex.Message);
Assert.Contains(" - Node:Name", ex.Message);
}
[Fact]
public void ThrowIfInvalid_is_noop_when_valid()
{
var cfg = Config(new() { ["Node:Name"] = "ok" });
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid(); // does not throw
}
}
Step 2: Run — expect FAIL
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter ConfigPreflightTests
Step 3: Implement ConfigPreflight.cs
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.Configuration;
/// <summary>
/// Fluent aggregator for validating raw <see cref="IConfiguration"/> BEFORE the host/DI container
/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via
/// <see cref="ThrowIfInvalid"/>. For options that flow through DI, prefer
/// <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions, TValidator}"/>.
/// </summary>
public sealed class ConfigPreflight
{
private readonly IConfiguration _configuration;
private readonly List<string> _failures = [];
private ConfigPreflight(IConfiguration configuration) => _configuration = configuration;
/// <summary>Starts a preflight over <paramref name="configuration"/>.</summary>
public static ConfigPreflight For(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
return new ConfigPreflight(configuration);
}
/// <summary>The accumulated failure messages (empty when valid).</summary>
public IReadOnlyList<string> Failures => _failures;
/// <summary>True when no failures have been accumulated.</summary>
public bool IsValid => _failures.Count == 0;
/// <summary>Requires the value at <paramref name="key"/> to satisfy <paramref name="predicate"/>.</summary>
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason)
{
ArgumentNullException.ThrowIfNull(predicate);
if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}");
return this;
}
/// <summary>Requires a non-empty value at <paramref name="key"/>.</summary>
public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key));
/// <summary>Requires a valid integer TCP port (1-65535) at <paramref name="key"/>.</summary>
public ConfigPreflight RequirePort(string key)
{
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));
}
/// <summary>Runs <paramref name="block"/> only when <paramref name="condition"/> holds (role-conditional rules).</summary>
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block)
{
ArgumentNullException.ThrowIfNull(block);
if (condition) block(this);
return this;
}
/// <summary>Throws <see cref="InvalidOperationException"/> listing all failures when invalid; otherwise returns.</summary>
public void ThrowIfInvalid()
{
if (_failures.Count > 0)
throw new InvalidOperationException(
$"Configuration validation failed:\n{string.Join("\n", _failures.Select(e => $" - {e}"))}");
}
private ConfigPreflight AddIf(string? message)
{
if (message is not null) _failures.Add(message);
return this;
}
}
Step 4: Run — expect PASS, then Commit (outer repo)
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "feat: ConfigPreflight raw-config aggregator"
Phase 2 — Package + register
Task 8: README + dotnet pack → verify single nupkg
Classification: small Estimated implement time: ~4 min Parallelizable with: none
Files:
- Create:
ZB.MOM.WW.Configuration/README.md - Create:
ZB.MOM.WW.Configuration/CLAUDE.md(short — mirror Telemetry's: purpose, build/test, layout)
Steps:
README.md— what the package is, the four types with a 6-line usage snippet each (OptionsValidatorBasesubclass,AddValidatedOptionscall,ConfigPreflightchain), build/test command (dotnet test),Version0.1.0.CLAUDE.md— one-screen orientation mirroring~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/CLAUDE.md.- Run full test + pack and verify exactly one nupkg:
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration
dotnet test
dotnet pack -c Release -o artifacts
ls artifacts/*.nupkg # expect: ZB.MOM.WW.Configuration.0.1.0.nupkg (exactly one)
Expected: all tests green; exactly one .nupkg at 0.1.0.
Step 4: Commit (nested repo)
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
git -C ~/Desktop/scadaproj commit -m "docs: README + CLAUDE.md; verify 0.1.0 pack"
Task 9: Register in indexes (components/README, root CLAUDE.md, upcoming.md)
Classification: small Estimated implement time: ~4 min Parallelizable with: none Depends on: Tasks 1, 2, 8
Files:
- Modify:
components/README.md(add registry row for Configuration) - Modify:
CLAUDE.md(component-normalization table row + a normalization paragraph mirroring the others) - Modify:
upcoming.md(tick the "Config + validation" Tier-2 item; add a "Suggested order" Done entry)
Steps:
components/README.md— add a Configuration row to the component registry table (StatusDraft, Applies to all three, GoalShared ZB.MOM.WW.Configuration lib (1 package), Folderconfiguration/).CLAUDE.md— add a row to the Component-normalization table (Config | Built (lib0.1.0) | …) and a short normalization paragraph like the Health/Telemetry ones (packages, tests count,dotnet pack→ 1 nupkg @ 0.1.0, consumer matrix, "not yet adopted → GAPS").upcoming.md— strike/annotate the Tier-2 "Config validation conventions" line as Done with links tocomponents/configuration/and../ZB.MOM.WW.Configuration/.
Acceptance: all three indexes reference the new component/library; links resolve.
Step 4: Commit (outer repo)
git -C ~/Desktop/scadaproj add components/README.md CLAUDE.md upcoming.md
git -C ~/Desktop/scadaproj commit -m "docs: register ZB.MOM.WW.Configuration in indexes"
Done criteria
dotnet testgreen inZB.MOM.WW.Configuration/;dotnet pack→ exactly one nupkgZB.MOM.WW.Configuration.0.1.0.nupkg.- Four public types implemented with the error-handling contract above;
ConfigPreflightmessage is byte-compatible with ScadaBridge'sStartupValidator. components/configuration/fully populated (spec, shared-contract, 3× current-state, GAPS, README).- Indexes updated (
components/README.md, rootCLAUDE.md,upcoming.md). - No sister-app code modified — adoption deferred to
components/configuration/GAPS.md.