docs: record ZB.MOM.WW.Configuration fleet-wide adoption + add design/plan
Configuration is now adopted across all three sister apps (local branches), so flip the status lines in CLAUDE.md, components/configuration/GAPS.md, and the lib README/CLAUDE.md from 'not adopted' to adopted (also corrects 27->42 tests). Adds the brainstorm design doc + bite-sized implementation plan (+tasks.json) under docs/plans/ that drove the adoption.
This commit is contained in:
@@ -124,7 +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/) |
|
| 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/) |
|
| 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/) |
|
| 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/) |
|
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | 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/) |
|
| 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
|
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
||||||
@@ -208,7 +208,12 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Configurat
|
|||||||
(.NET 10; single package `ZB.MOM.WW.Configuration`; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0).
|
(.NET 10; single package `ZB.MOM.WW.Configuration`; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0).
|
||||||
The implementation plan is at
|
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).
|
[`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).
|
**Adopted across all three apps on 2026-06-01** (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's
|
||||||
|
local default branch (`main`/`master`) — merged, **not yet pushed** to remotes; the package was first
|
||||||
|
published to the Gitea feed. Behaviour-preserving onto `OptionsValidatorBase`/`AddValidatedOptions`
|
||||||
|
for MxGateway + ScadaBridge (validator messages byte-identical), `StartupValidator` → `ConfigPreflight`
|
||||||
|
for ScadaBridge, and net-new `Ldap`/`OpcUa` validators for OtOpcUa. Per-app result 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
|
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` →
|
single package; ScadaBridge is the heaviest adopter (per-module validators + `StartupValidator` →
|
||||||
`ConfigPreflight`); OtOpcUa adoption is additive (it has no `IValidateOptions` usage today).
|
`ConfigPreflight`); OtOpcUa adoption is additive (it has no `IValidateOptions` usage today).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtO
|
|||||||
|
|
||||||
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.
|
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`.
|
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ ZB.MOM.WW.Configuration/
|
|||||||
|
|
||||||
## Status
|
## 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:
|
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes) — per-app result is tracked in:
|
||||||
|
|
||||||
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ No third-party packages; no ASP.NET Core framework reference.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Built at 0.1.0. Not yet adopted by the three apps.** Adoption is tracked in the component backlog:
|
**Built at 0.1.0. Adopted across all three apps on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption is tracked in the component backlog:
|
||||||
|
|
||||||
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Configuration validation — gaps & adoption backlog
|
# Configuration validation — gaps & adoption backlog
|
||||||
|
|
||||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to adopt
|
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
|
the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (42 tests) at
|
||||||
[`../../ZB.MOM.WW.Configuration/`](../../ZB.MOM.WW.Configuration/) but **NOT YET ADOPTED** by any
|
[`../../ZB.MOM.WW.Configuration/`](../../ZB.MOM.WW.Configuration/) and was **ADOPTED across all three
|
||||||
app — so every item below is an *adoption* item, not a library-build item. This mirrors the Auth /
|
apps on 2026-06-01** — published to the Gitea feed, then consumed on each repo's local default branch
|
||||||
UI-Theme / Health pattern: the shared library is built first; adoption is opt-in and tracked here,
|
(merged, **not yet pushed** to remotes). The adoption items below are now largely closed: MxGateway +
|
||||||
not forced. (Unlike the observability pass, there is **no in-pass sister-repo adoption** in this
|
ScadaBridge migrated to `OptionsValidatorBase`/`AddValidatedOptions` behaviour-preservingly (validator
|
||||||
release.)
|
messages byte-identical), ScadaBridge's `StartupValidator` → `ConfigPreflight`, and OtOpcUa gained
|
||||||
|
net-new `Ldap`/`OpcUa` validators (plus a follow-on pass: real `Security:Ldap` binding, `ValidateOnStart`
|
||||||
|
wired for ScadaBridge Cluster/HealthMonitoring, and assorted hardening).
|
||||||
|
|
||||||
Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Design: Deploy `ZB.MOM.WW.Configuration` fleet-wide
|
||||||
|
|
||||||
|
**Date:** 2026-06-01
|
||||||
|
**Status:** Approved — ready for implementation planning (writing-plans).
|
||||||
|
**Scope:** Adopt the shared `ZB.MOM.WW.Configuration` library into all three sister apps
|
||||||
|
(OtOpcUa, MxAccessGateway, ScadaBridge).
|
||||||
|
|
||||||
|
> Every state claim below was **code-verified on 2026-06-01**, not taken from the
|
||||||
|
> `components/*/GAPS.md` prose — those docs proved unreliable in both directions (they
|
||||||
|
> claimed Health was un-adopted when it is fully adopted, and claimed Telemetry was
|
||||||
|
> adopted before it was). See memory `component-status-claims-are-optimistic`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Why this module
|
||||||
|
|
||||||
|
Verified fleet-wide adoption state (real `PackageReference` + usage scan of the three
|
||||||
|
sister-app `src/` trees, plus Gitea-feed `curl`):
|
||||||
|
|
||||||
|
| Module | OtOpcUa | MxAccessGateway | ScadaBridge | Status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Health | ✅ | ✅ | ✅ | already deployed fleet-wide |
|
||||||
|
| Telemetry (observability) | ✅ | ✅ | ✅ | already deployed fleet-wide |
|
||||||
|
| **Configuration** | — | — | — | **chosen: not adopted anywhere** |
|
||||||
|
| Auth | — | — | — | not adopted |
|
||||||
|
| UI Theme | — | — | — | not adopted |
|
||||||
|
| Audit | — | — | — | not adopted |
|
||||||
|
|
||||||
|
Configuration was chosen as the next fleet-wide adoption because it is the same
|
||||||
|
cross-cutting-infra flavour as the already-done Health + Telemetry, it is the
|
||||||
|
lowest-risk (behaviour-preserving for the two heavy consumers), and it still delivers
|
||||||
|
real new value (OtOpcUa gains fail-fast startup validation it lacks entirely today).
|
||||||
|
|
||||||
|
### Decisions locked during brainstorming
|
||||||
|
- **Module:** Configuration.
|
||||||
|
- **OtOpcUa depth:** add **real** validators (net-new `Ldap`/`OpcUa` startup validation),
|
||||||
|
not just a package reference.
|
||||||
|
- **Rollout:** per-repo **sequential**, increasing risk order: Foundation → MxGateway →
|
||||||
|
OtOpcUa → ScadaBridge; each repo on its own branch, verified green before the next.
|
||||||
|
- **ScadaBridge `StartupValidator` → `ConfigPreflight`:** included in this pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal & scope
|
||||||
|
|
||||||
|
Move the config-validation **plumbing** (failure accumulation, the bind+validate+
|
||||||
|
`ValidateOnStart` triple, the pre-host raw-config aggregator) into the shared library so
|
||||||
|
it is written once; leave every **domain rule and failure message** per-project.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- OtOpcUa's `DraftValidator` / `sp_ValidateDraft` — domain *content* validation over
|
||||||
|
database draft rows, dormant in `src/`, not the host-config concern this library owns.
|
||||||
|
- Any change to rule wording or validation semantics (behaviour-preserving except the
|
||||||
|
*additive* OtOpcUa validators).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The contract being adopted (verified public API)
|
||||||
|
|
||||||
|
From `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/`:
|
||||||
|
|
||||||
|
- **`OptionsValidatorBase<TOptions>`** — abstract `IValidateOptions<TOptions>`. Override
|
||||||
|
`protected abstract void Validate(ValidationBuilder, TOptions)`; the base creates the
|
||||||
|
builder, runs the override, and returns `Success` only when no failures were recorded
|
||||||
|
(else `Fail(builder.Failures)`).
|
||||||
|
- **`ValidationBuilder`** — rule primitives `Required`, `Port`, `HostPort`,
|
||||||
|
`PositiveTimeSpan`, `OneOf`, `MinCount`, plus `RequireThat(bool, message)` and
|
||||||
|
`Add(message)` for custom / cross-field rules. `Failures` / `IsValid` expose state.
|
||||||
|
- **`ServiceCollectionExtensions.AddValidatedOptions<TOptions, TValidator>(config, sectionPath)`**
|
||||||
|
— `TryAddEnumerable` the validator (singleton) + `AddOptions().Bind(section).ValidateOnStart()`
|
||||||
|
in one call; returns the `OptionsBuilder` for chaining.
|
||||||
|
- **`ConfigPreflight.For(IConfiguration)`** — fluent pre-host checker for raw config
|
||||||
|
before the DI container exists: `RequireValue(key)`, `RequirePort(key)`,
|
||||||
|
`Require(key, predicate, reason)`, `When(condition, block)`, terminating in
|
||||||
|
`ThrowIfInvalid()` (throws `InvalidOperationException` listing all failures).
|
||||||
|
|
||||||
|
Library health: `dotnet test` → **42 passed, 0 failed** (the `CLAUDE.md` "27 tests" line
|
||||||
|
is stale-low; the suite passes regardless).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Foundation phase (must land before any repo adopts)
|
||||||
|
|
||||||
|
This is the part the status docs hide. Verified 2026-06-01:
|
||||||
|
|
||||||
|
1. **Pack + push the package.** `ZB.MOM.WW.Configuration` is **404 on the Gitea feed**
|
||||||
|
(`registration/zb.mom.ww.configuration/index.json`), while the known-adopted Health
|
||||||
|
package returns 200. `dotnet pack -c Release` then push the `.nupkg` to
|
||||||
|
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget`.
|
||||||
|
2. **Per-app feed wiring** (all three `nuget.config` files): the `dohertj2-gitea`
|
||||||
|
`packageSourceMapping` currently routes only `ZB.MOM.WW.MxGateway.*`,
|
||||||
|
`ZB.MOM.WW.Health*`, `ZB.MOM.WW.Telemetry*`. Add
|
||||||
|
`<package pattern="ZB.MOM.WW.Configuration" />`. Without this, restore fails even with
|
||||||
|
the package on the feed.
|
||||||
|
3. **Central version pin** in each app's `Directory.Packages.props`:
|
||||||
|
`<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />`.
|
||||||
|
4. **Verify gate:** `curl` the registration index → **200** before any repo work begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Per-repo adoption (sequential)
|
||||||
|
|
||||||
|
Each repo: branch `feat/adopt-zb-configuration`, `PackageReference` (no version — central
|
||||||
|
package management), migrate, `dotnet build` + `dotnet test` green, then move on.
|
||||||
|
|
||||||
|
### Repo 1 — MxAccessGateway (medium; pure refactor)
|
||||||
|
- `PackageReference Include="ZB.MOM.WW.Configuration"` in
|
||||||
|
`src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj`.
|
||||||
|
- `GatewayOptionsValidator : IValidateOptions<GatewayOptions>` →
|
||||||
|
`: OptionsValidatorBase<GatewayOptions>`. Drop the private `List<string>` and the
|
||||||
|
`Count == 0 ? Success : Fail` tail (now the base's job). Map private helpers:
|
||||||
|
`AddIfBlank` → `Required`; `AddIfNotPositive` / `AddIfNegative` → `RequireThat(... , msg)`.
|
||||||
|
Keep `AddIfInvalidPath`, the `.exe`-extension rule, the cross-field
|
||||||
|
`HeartbeatGraceSeconds >= HeartbeatIntervalSeconds`, range checks, and all nine
|
||||||
|
sub-validators as `RequireThat`/`Add` custom rules. **Every message string unchanged.**
|
||||||
|
- `AddGatewayConfiguration`'s `AddOptions().BindConfiguration(SectionName).ValidateOnStart()`
|
||||||
|
+ `AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>()` →
|
||||||
|
`services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(config, GatewayOptions.SectionName)`.
|
||||||
|
Keep the separate `IGatewayConfigurationProvider` registration.
|
||||||
|
|
||||||
|
### Repo 2 — OtOpcUa (lightest base, but net-new validation added)
|
||||||
|
- `PackageReference` in
|
||||||
|
`src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`.
|
||||||
|
- New `LdapOptionsValidator : OptionsValidatorBase<LdapOptions>`
|
||||||
|
(`LdapOptions` lives in `ZB.MOM.WW.OtOpcUa.Security/Ldap/`): `Required` on Server /
|
||||||
|
SearchBase (and other not-optional fields). `Program.cs:99`
|
||||||
|
`AddOptions<LdapOptions>().Bind(GetSection("Ldap"))` →
|
||||||
|
`AddValidatedOptions<LdapOptions, LdapOptionsValidator>(config, "Ldap")`.
|
||||||
|
- New validator for the `OpcUa` section; replace the imperative
|
||||||
|
`GetSection("OpcUa").Bind(options)` at `OtOpcUaServerHostedService.cs:63` with validated
|
||||||
|
options resolved from DI. Exact rule list finalized in the implementation plan from the
|
||||||
|
real `OpcUaOptions` fields (ports → `Port`, endpoints → `HostPort`, required strings →
|
||||||
|
`Required`, durations → `PositiveTimeSpan`).
|
||||||
|
- New unit tests for both validators (valid config passes; each missing/invalid field
|
||||||
|
produces its message).
|
||||||
|
|
||||||
|
### Repo 3 — ScadaBridge (heaviest; refactor + preflight)
|
||||||
|
- `PackageReference` in `src/ZB.MOM.WW.ScadaBridge.Host/...csproj` and the module projects
|
||||||
|
that own validators (ClusterInfrastructure, Security, HealthMonitoring, AuditLog).
|
||||||
|
- Four `*OptionsValidator` → `OptionsValidatorBase<T>`:
|
||||||
|
- `ClusterOptionsValidator`: `SeedNodes` ≥ 2 → `MinCount`; strategy ∈ set → `OneOf`;
|
||||||
|
three positive `TimeSpan` → `PositiveTimeSpan`; cross-field heartbeat/threshold and
|
||||||
|
`DownIfAlone`/`MinNrOfMembers` → `RequireThat`.
|
||||||
|
- `SecurityOptionsValidator`: `Required` LdapServer / LdapSearchBase (JwtSigningKey stays
|
||||||
|
validated in `JwtTokenService` ctor — unchanged).
|
||||||
|
- `HealthMonitoringOptionsValidator`: three `PositiveTimeSpan` + cross-field
|
||||||
|
`CentralOfflineTimeout >= OfflineTimeout` → `RequireThat`. Preserve the idempotent
|
||||||
|
registration called from all three `Add*HealthMonitoring` entry points.
|
||||||
|
- `AuditLogOptionsValidator`: positive/`>=`/range checks → `RequireThat`.
|
||||||
|
- Each module `AddXxx` → `AddValidatedOptions<T, TValidator>` where the section binding
|
||||||
|
shape allows (preserve `ValidateOnStart` + `TryAddEnumerable` semantics).
|
||||||
|
- `StartupValidator.Validate(configuration)` at `Program.cs:41` → `ConfigPreflight.For(
|
||||||
|
configuration).RequireValue(...)/RequirePort(...)/When(...).ThrowIfInvalid()`. **Must
|
||||||
|
keep `StartupValidatorTests` green** — the thrown message is byte-compatible with
|
||||||
|
`ConfigPreflight.ThrowIfInvalid()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error handling / behaviour preservation
|
||||||
|
|
||||||
|
- Failure surface is unchanged everywhere: `OptionsValidationException` thrown at host
|
||||||
|
start via `ValidateOnStart`; `ConfigPreflight.ThrowIfInvalid()` throws the same
|
||||||
|
`InvalidOperationException` text ScadaBridge's `StartupValidator` throws today.
|
||||||
|
- MxGateway + ScadaBridge: **zero message changes** — the existing validator tests and
|
||||||
|
`StartupValidatorTests` are the regression guard.
|
||||||
|
- OtOpcUa: **additive** — a config that was silently accepted (then failed late as an LDAP
|
||||||
|
error on first login, or an OPC UA bind error) now fails fast at startup. That is the
|
||||||
|
intended improvement, called out so it is not mistaken for a regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing & verification (gate per repo, before moving on)
|
||||||
|
|
||||||
|
- Library: re-run `dotnet test` (already 42 green).
|
||||||
|
- Each repo on its branch: `dotnet build` + `dotnet test` green.
|
||||||
|
- MxGateway: `src/MxGateway.Tests` (fake worker — no MXAccess needed).
|
||||||
|
- OtOpcUa: full solution test + the new validator unit tests.
|
||||||
|
- ScadaBridge: four validator tests + `StartupValidatorTests` still green.
|
||||||
|
- **Restore proof** per repo: a clean restore pulls `ZB.MOM.WW.Configuration 0.1.0` from
|
||||||
|
Gitea — confirms both the push and the source-mapping edit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Package 404 / source-mapping omission breaks restore | Foundation phase + per-repo restore proof gate. |
|
||||||
|
| A "trivial" message tweak during refactor changes behaviour | Behaviour-preserving rule; existing tests fail loudly if a message drifts. |
|
||||||
|
| ScadaBridge preflight message drift | `StartupValidatorTests` must pass unchanged. |
|
||||||
|
| OtOpcUa `OpcUa`/`Ldap` rule set guesses wrong fields | Plan finalizes rules from the actual options classes; additive-only. |
|
||||||
|
| `AddValidatedOptions` singleton constraint (no scoped deps in validators) | All four ScadaBridge + the gateway validators are already stateless singletons. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deliverable & next step
|
||||||
|
|
||||||
|
This design doc, then a step-by-step implementation plan produced via the **writing-plans**
|
||||||
|
skill. No source changes in any repo until the plan is approved and execution begins.
|
||||||
|
|
||||||
|
> Note: `~/Desktop/scadaproj` is **not** a git repository, so this design is not committed
|
||||||
|
> here; it is saved under `docs/plans/`. (Per memory, do not `git init` it without asking.)
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
# Deploy `ZB.MOM.WW.Configuration` Fleet-Wide — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Adopt the shared `ZB.MOM.WW.Configuration` library into all three sister apps (MxAccessGateway, OtOpcUa, ScadaBridge) so the config-validation *plumbing* is owned by the library while *domain rules and messages* stay per-project.
|
||||||
|
|
||||||
|
**Architecture:** Foundation first (publish the package to the Gitea feed + wire each app's NuGet source-mapping/version pin), then per-repo sequential adoption in increasing-risk order: MxGateway → OtOpcUa → ScadaBridge. Each repo on its own `feat/adopt-zb-configuration` branch, built + tested green before the next.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, `Microsoft.Extensions.Options` (`IValidateOptions`, `ValidateOnStart`), xUnit, central package management, Gitea NuGet feed.
|
||||||
|
|
||||||
|
**Design doc:** [`2026-06-01-deploy-zb-configuration-design.md`](2026-06-01-deploy-zb-configuration-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Decisions & corrections baked into this plan (read first)
|
||||||
|
|
||||||
|
1. **Behaviour-preserving = use `RequireThat`, NOT the wording-imposing primitives.**
|
||||||
|
`ValidationBuilder.Required/Port/PositiveTimeSpan/...` emit **standardized** messages
|
||||||
|
(`"{field} is required"`, `"{field} must be between 1 and 65535 (was …)"`, `"{field} must be a
|
||||||
|
positive duration (was …)"`). MxGateway and ScadaBridge use **bespoke** messages (often with
|
||||||
|
trailing rationale, e.g. `"…; it is used directly as a PeriodicTimer period."`). Mapping their
|
||||||
|
checks onto the primitives would **silently change the messages and break the existing validator
|
||||||
|
tests.** Therefore, for MxGateway + ScadaBridge migrations: keep every check as
|
||||||
|
`builder.RequireThat(<condition>, "<exact existing message>")` (or `builder.Add("<message>")` for
|
||||||
|
unconditional adds). The `components/configuration/GAPS.md` "→ Required / → PositiveTimeSpan"
|
||||||
|
mappings are **wrong for byte-compatibility** — do not follow them. The wording-imposing
|
||||||
|
primitives are used **only in OtOpcUa**, where the validators are net-new and we author the
|
||||||
|
wording fresh.
|
||||||
|
|
||||||
|
2. **OtOpcUa gets real, net-new validators** (Ldap + OpcUa) — approved scope. This adds fail-fast
|
||||||
|
startup validation OtOpcUa lacks today; a previously silently-accepted bad config now throws at
|
||||||
|
host start. That is the intended improvement, not a regression.
|
||||||
|
|
||||||
|
3. **Flagged discrepancy (do not silently "fix"):** `OtOpcUa Program.cs:99` binds
|
||||||
|
`GetSection("Ldap")` but `LdapOptions.SectionName = "Authentication:Ldap"`. This plan
|
||||||
|
**preserves** the current `"Ldap"` section path and surfaces the mismatch to the user in Task 3.
|
||||||
|
Do not switch to the constant without an explicit decision.
|
||||||
|
|
||||||
|
4. **Out of scope:** OtOpcUa's `DraftValidator` / `sp_ValidateDraft` (dormant domain-content
|
||||||
|
validation), and any rule-wording change to existing validators.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Foundation — publish package + wire all three consumers
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (everything else depends on this)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Pack source: `~/Desktop/scadaproj/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx`
|
||||||
|
- Modify: `~/Desktop/MxAccessGateway/nuget.config`
|
||||||
|
- Modify: `~/Desktop/OtOpcUa/NuGet.config`
|
||||||
|
- Modify: `~/Desktop/OtOpcUa/Directory.Packages.props`
|
||||||
|
- Modify: `~/Desktop/ScadaBridge/nuget.config`
|
||||||
|
- Modify: `~/Desktop/ScadaBridge/Directory.Packages.props`
|
||||||
|
|
||||||
|
> Context: verified 2026-06-01 — `ZB.MOM.WW.Configuration` is **404** on the Gitea feed (Health is
|
||||||
|
> 200), and **no** app's `packageSourceMapping` routes it to Gitea. Both must be fixed before any
|
||||||
|
> repo can restore it. The lib builds clean: `dotnet test` = **42 passed**.
|
||||||
|
|
||||||
|
**Step 1: Verify the lib is green**
|
||||||
|
|
||||||
|
Run: `cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test ZB.MOM.WW.Configuration.slnx`
|
||||||
|
Expected: `Passed! - Failed: 0, Passed: 42`.
|
||||||
|
|
||||||
|
**Step 2: Pack**
|
||||||
|
|
||||||
|
Run: `cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts`
|
||||||
|
Expected: `ZB.MOM.WW.Configuration.0.1.0.nupkg` in `./artifacts`.
|
||||||
|
|
||||||
|
**Step 3: Push to Gitea** (use the same credentials/source already used for Health/Telemetry)
|
||||||
|
|
||||||
|
Run: `dotnet nuget push ./artifacts/ZB.MOM.WW.Configuration.0.1.0.nupkg --source dohertj2-gitea` (or the full feed URL `https://gitea.dohertylan.com/api/packages/dohertj2/nuget` with API key).
|
||||||
|
|
||||||
|
**Step 4: Verify it's live**
|
||||||
|
|
||||||
|
Run: `curl -s -o /dev/null -w "%{http_code}\n" https://gitea.dohertylan.com/api/packages/dohertj2/nuget/registration/zb.mom.ww.configuration/index.json`
|
||||||
|
Expected: `200`.
|
||||||
|
|
||||||
|
**Step 5: Add source-mapping in each `nuget.config`**
|
||||||
|
|
||||||
|
In all three (`MxAccessGateway/nuget.config`, `OtOpcUa/NuGet.config`, `ScadaBridge/nuget.config`),
|
||||||
|
inside the `dohertj2-gitea` `<packageSource>` block, add alongside the existing Health/Telemetry
|
||||||
|
patterns:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<package pattern="ZB.MOM.WW.Configuration" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Pin the version (central package management)**
|
||||||
|
|
||||||
|
In `OtOpcUa/Directory.Packages.props` and `ScadaBridge/Directory.Packages.props`, add to the
|
||||||
|
`<ItemGroup>` of `<PackageVersion>`s:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: MxAccessGateway pins versions inline on the `PackageReference` (verified: its Health refs
|
||||||
|
> carry `Version="0.1.0"`), so its pin happens in Task 2 on the `PackageReference` itself. Confirm
|
||||||
|
> per repo whether `ManagePackageVersionsCentrally` is set and follow the repo's existing convention.
|
||||||
|
|
||||||
|
**Step 7: Restore proof**
|
||||||
|
|
||||||
|
Run (one app is enough): `cd ~/Desktop/ScadaBridge && dotnet restore` after Task 7 adds the
|
||||||
|
reference — OR a throwaway probe now: temporarily add the ref to a scratch project. Minimum gate:
|
||||||
|
Step 4 returns 200 and the mapping/pin edits are saved in all three repos.
|
||||||
|
|
||||||
|
**Step 8: Commit each touched repo** (these are separate git repos; `scadaproj` itself is NOT a git repo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in each of MxAccessGateway / OtOpcUa / ScadaBridge:
|
||||||
|
git checkout -b feat/adopt-zb-configuration
|
||||||
|
git add nuget.config NuGet.config Directory.Packages.props
|
||||||
|
git commit -m "build: add ZB.MOM.WW.Configuration feed mapping + version pin"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: MxAccessGateway — migrate `GatewayOptionsValidator` to the shared base
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj`
|
||||||
|
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs`
|
||||||
|
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs`
|
||||||
|
- Test (regression guard, do not change): `~/Desktop/MxAccessGateway/src/MxGateway.Tests/**` (the existing `GatewayOptionsValidator` tests)
|
||||||
|
|
||||||
|
**Step 1: Add the package reference**
|
||||||
|
|
||||||
|
In `ZB.MOM.WW.MxGateway.Server.csproj`, beside the existing Health refs:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Re-base the validator (messages byte-identical)**
|
||||||
|
|
||||||
|
`GatewayOptionsValidator.cs` — change the class + entry point and retarget the sub-validators and
|
||||||
|
helpers from `List<string>` to `ValidationBuilder`. The nine `ValidateXxx` methods and the four
|
||||||
|
helpers stay; only their parameter type and the `.Add` target change.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.Configuration; // add
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions> // was : IValidateOptions<GatewayOptions>
|
||||||
|
{
|
||||||
|
private const int MinimumMaxMessageBytes = 1024;
|
||||||
|
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
protected override void Validate(ValidationBuilder builder, GatewayOptions options) // was public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
||||||
|
{
|
||||||
|
ValidateAuthentication(options.Authentication, builder);
|
||||||
|
ValidateLdap(options.Ldap, builder);
|
||||||
|
ValidateWorker(options.Worker, builder);
|
||||||
|
ValidateSessions(options.Sessions, builder);
|
||||||
|
ValidateEvents(options.Events, builder);
|
||||||
|
ValidateDashboard(options.Dashboard, builder);
|
||||||
|
ValidateProtocol(options.Protocol, builder);
|
||||||
|
ValidateAlarms(options.Alarms, builder);
|
||||||
|
ValidateTls(options.Tls, builder);
|
||||||
|
// NOTE: no List<string> and no `return Count==0 ? Success : Fail` — the base does that.
|
||||||
|
}
|
||||||
|
// ... sub-validators unchanged except `List<string> failures` param → `ValidationBuilder builder`
|
||||||
|
// and every `failures.Add(msg)` → `builder.Add(msg)`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper conversions (keep the four helpers; retarget to the builder — **messages unchanged**):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static void AddIfBlank(string? value, string message, ValidationBuilder builder) =>
|
||||||
|
builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
|
||||||
|
|
||||||
|
private static void AddIfNotPositive(int value, string message, ValidationBuilder builder) =>
|
||||||
|
builder.RequireThat(value > 0, message);
|
||||||
|
|
||||||
|
private static void AddIfNegative(int value, string message, ValidationBuilder builder) =>
|
||||||
|
builder.RequireThat(value >= 0, message);
|
||||||
|
|
||||||
|
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return;
|
||||||
|
try { _ = Path.GetFullPath(value); }
|
||||||
|
catch (ArgumentException) { builder.Add(message); }
|
||||||
|
catch (NotSupportedException) { builder.Add(message); }
|
||||||
|
catch (PathTooLongException) { builder.Add(message); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> DO NOT replace `AddIfBlank` with `builder.Required(...)` etc. — that changes the message text.
|
||||||
|
> Mechanical rule for the bodies: `failures.Add(x)` → `builder.Add(x)`; the early-`return` guards
|
||||||
|
> (e.g. `if (!options.Enabled) return;` in `ValidateLdap`/`ValidateAlarms`, and the
|
||||||
|
> `Enum.IsDefined` short-circuit `return` in `ValidateAuthentication`) stay exactly as written.
|
||||||
|
|
||||||
|
**Step 3: Collapse the DI triple → `AddValidatedOptions`**
|
||||||
|
|
||||||
|
`GatewayConfigurationServiceCollectionExtensions.cs` — replace the
|
||||||
|
`AddOptions().BindConfiguration(SectionName).ValidateOnStart()` + `AddSingleton<IValidateOptions…>`
|
||||||
|
trio with one call (keep the separate `IGatewayConfigurationProvider` registration):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.Configuration; // add
|
||||||
|
|
||||||
|
// was:
|
||||||
|
// services.AddOptions<GatewayOptions>().BindConfiguration(GatewayOptions.SectionName).ValidateOnStart();
|
||||||
|
// services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
||||||
|
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||||
|
configuration, GatewayOptions.SectionName);
|
||||||
|
```
|
||||||
|
|
||||||
|
> `AddValidatedOptions` takes an `IConfiguration`; if `AddGatewayConfiguration` doesn't already
|
||||||
|
> receive one, thread `builder.Configuration` (or `IConfiguration`) into it. The original used
|
||||||
|
> `BindConfiguration(SectionName)` (path read off the type); `AddValidatedOptions` takes the path as
|
||||||
|
> the `sectionPath` argument — pass `GatewayOptions.SectionName`. Net binding is identical.
|
||||||
|
|
||||||
|
**Step 4: Build + test (regression guard)**
|
||||||
|
|
||||||
|
Run: `cd ~/Desktop/MxAccessGateway && dotnet build src/MxGateway.sln && dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj`
|
||||||
|
Expected: build succeeds; **all existing `GatewayOptionsValidator` tests pass unchanged** (proves messages are byte-identical). No MXAccess needed (fake worker).
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Server
|
||||||
|
git commit -m "refactor: adopt ZB.MOM.WW.Configuration in MxGateway (behaviour-preserving)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: OtOpcUa — net-new `LdapOptionsValidator`
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 4 (different files — but keep on the same OtOpcUa branch)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`
|
||||||
|
- Create: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`
|
||||||
|
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:99`
|
||||||
|
- Create: `~/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/LdapOptionsValidatorTests.cs` (match the repo's actual Host test project path — verify before writing)
|
||||||
|
|
||||||
|
**Step 1: Package reference**
|
||||||
|
|
||||||
|
In `ZB.MOM.WW.OtOpcUa.Host.csproj` (no `Version` — central management, pinned in Task 1):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write the failing test** (`LdapOptionsValidatorTests.cs`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class LdapOptionsValidatorTests
|
||||||
|
{
|
||||||
|
private static ValidateOptionsResult Run(LdapOptions o) =>
|
||||||
|
new LdapOptionsValidator().Validate(null, o);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Valid_options_pass() =>
|
||||||
|
Assert.True(Run(new LdapOptions { Enabled = true, Server = "ldap", SearchBase = "dc=x", Port = 389 }).Succeeded);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_skips_all_checks() =>
|
||||||
|
Assert.True(Run(new LdapOptions { Enabled = false, Server = "", SearchBase = "", Port = 0 }).Succeeded);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Blank_server_fails_when_enabled() =>
|
||||||
|
Assert.Contains("Authentication:Ldap:Server is required when LDAP login is enabled.",
|
||||||
|
Run(new LdapOptions { Enabled = true, Server = "", SearchBase = "dc=x", Port = 389 }).Failures!);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run it — expect FAIL** (`LdapOptionsValidator` not defined).
|
||||||
|
Run: `cd ~/Desktop/OtOpcUa && dotnet test --filter FullyQualifiedName~LdapOptionsValidatorTests`
|
||||||
|
|
||||||
|
**Step 4: Implement** (`LdapOptionsValidator.cs`) — gate on `Enabled` like MxGateway; author wording fresh
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
|
||||||
|
{
|
||||||
|
protected override void Validate(ValidationBuilder builder, LdapOptions options)
|
||||||
|
{
|
||||||
|
if (!options.Enabled) return;
|
||||||
|
builder.RequireThat(!string.IsNullOrWhiteSpace(options.Server),
|
||||||
|
"Authentication:Ldap:Server is required when LDAP login is enabled.");
|
||||||
|
builder.RequireThat(!string.IsNullOrWhiteSpace(options.SearchBase),
|
||||||
|
"Authentication:Ldap:SearchBase is required when LDAP login is enabled.");
|
||||||
|
builder.Port(options.Port, "Authentication:Ldap:Port");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Wire the binding** — `Program.cs:99`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// was: builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||||
|
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, "Ldap");
|
||||||
|
```
|
||||||
|
|
||||||
|
> **FLAG to the user (do not auto-resolve):** the section path stays `"Ldap"` to preserve current
|
||||||
|
> behaviour, even though `LdapOptions.SectionName == "Authentication:Ldap"`. The message strings
|
||||||
|
> above intentionally say `Authentication:Ldap:` (matching the conceptual section name); if the user
|
||||||
|
> prefers the path to match the constant, change both the `sectionPath` and re-confirm config keys.
|
||||||
|
|
||||||
|
**Step 6: Run tests — expect PASS.** `dotnet test --filter FullyQualifiedName~LdapOptionsValidatorTests`
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Server/ZB.MOM.WW.OtOpcUa.Host tests
|
||||||
|
git commit -m "feat: add fail-fast LDAP options validation in OtOpcUa via ZB.MOM.WW.Configuration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: OtOpcUa — net-new `OpcUa` validator + route through DI
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs`
|
||||||
|
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (register validated options)
|
||||||
|
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs:41-63` (inject `IOptions`, drop imperative bind)
|
||||||
|
- Create: `OpcUaApplicationHostOptionsValidatorTests.cs` (Host test project)
|
||||||
|
|
||||||
|
> Why high-risk: changes the hosted service constructor and makes a bad `OpcUa` section throw at host
|
||||||
|
> start (`ValidateOnStart`). Today `StartAsync` swallows SDK-start exceptions (`OtOpcUaServerHostedService.cs:75-82`);
|
||||||
|
> validation now fails fast *before* that path. This is the intended fail-fast improvement, but it is
|
||||||
|
> a behaviour change — keep it isolated and tested.
|
||||||
|
|
||||||
|
**Step 1: Write the failing test** — valid passes; bad port fails with fresh primitive wording
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class OpcUaApplicationHostOptionsValidatorTests
|
||||||
|
{
|
||||||
|
private static ValidateOptionsResult Run(OpcUaApplicationHostOptions o) =>
|
||||||
|
new OpcUaApplicationHostOptionsValidator().Validate(null, o);
|
||||||
|
|
||||||
|
[Fact] public void Defaults_pass() => Assert.True(Run(new OpcUaApplicationHostOptions()).Succeeded);
|
||||||
|
|
||||||
|
[Fact] public void Bad_port_fails() =>
|
||||||
|
Assert.Contains("OpcUa:OpcUaPort must be between 1 and 65535 (was 0)",
|
||||||
|
Run(new OpcUaApplicationHostOptions { OpcUaPort = 0 }).Failures!);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run — expect FAIL.**
|
||||||
|
|
||||||
|
**Step 3: Implement the validator** — net-new, so use the wording-imposing primitives freely
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<OpcUaApplicationHostOptions>
|
||||||
|
{
|
||||||
|
protected override void Validate(ValidationBuilder builder, OpcUaApplicationHostOptions o)
|
||||||
|
{
|
||||||
|
builder.Required(o.ApplicationName, "OpcUa:ApplicationName");
|
||||||
|
builder.Required(o.ApplicationUri, "OpcUa:ApplicationUri");
|
||||||
|
builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
|
||||||
|
builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
|
||||||
|
builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
|
||||||
|
builder.MinCount(o.EnabledSecurityProfiles, 1, "OpcUa:EnabledSecurityProfiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Register validated options** — `Program.cs` (near the other host registrations)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
|
||||||
|
builder.Configuration, "OpcUa");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Consume via DI in the hosted service** — `OtOpcUaServerHostedService.cs`
|
||||||
|
|
||||||
|
Add `IOptions<OpcUaApplicationHostOptions> options` to the constructor (store `_options`), then
|
||||||
|
replace lines 62-63:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// was:
|
||||||
|
// var options = new OpcUaApplicationHostOptions();
|
||||||
|
// _configuration.GetSection("OpcUa").Bind(options);
|
||||||
|
var options = _options.Value;
|
||||||
|
```
|
||||||
|
|
||||||
|
(If `_configuration` becomes unused after this, leave it — other members may use it; verify before removing.)
|
||||||
|
|
||||||
|
**Step 6: Run tests + full build.**
|
||||||
|
Run: `cd ~/Desktop/OtOpcUa && dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test ZB.MOM.WW.OtOpcUa.slnx`
|
||||||
|
Expected: green, including the two new tests.
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Server/ZB.MOM.WW.OtOpcUa.Host tests
|
||||||
|
git commit -m "feat: validate OpcUa host options at startup (route through IOptions + ValidateOnStart)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: ScadaBridge — migrate the four `*OptionsValidator` to the shared base
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~6 min (split per-validator if needed — they are independent files)
|
||||||
|
**Parallelizable with:** Task 6 (StartupValidator is a different file)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify (add `PackageReference Include="ZB.MOM.WW.Configuration"` to each owning project):
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/…csproj`
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.Security/…csproj`
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/…csproj`
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.AuditLog/…csproj`
|
||||||
|
- Modify:
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs`
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs`
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs`
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs`
|
||||||
|
- Test (regression guard, do not change): the existing four validator test classes.
|
||||||
|
|
||||||
|
**Transformation (identical shape for all four):**
|
||||||
|
1. `: IValidateOptions<T>` → `: OptionsValidatorBase<T>` (`using ZB.MOM.WW.Configuration;`).
|
||||||
|
2. `public ValidateOptionsResult Validate(string? name, T options)` →
|
||||||
|
`protected override void Validate(ValidationBuilder builder, T options)`.
|
||||||
|
3. Delete `var failures = new List<string>();` and the
|
||||||
|
`return failures.Count … ? Fail(failures) : Success;` tail.
|
||||||
|
4. Each `if (<bad>) failures.Add("<msg>");` → `builder.RequireThat(!(<bad>), "<msg>");`
|
||||||
|
(i.e. invert the condition to the *valid* predicate), **message unchanged**.
|
||||||
|
|
||||||
|
Worked example — `HealthMonitoringOptionsValidator` (the others follow the same recipe):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||||
|
|
||||||
|
public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase<HealthMonitoringOptions>
|
||||||
|
{
|
||||||
|
protected override void Validate(ValidationBuilder builder, HealthMonitoringOptions options)
|
||||||
|
{
|
||||||
|
builder.RequireThat(options.ReportInterval > TimeSpan.Zero,
|
||||||
|
$"ScadaBridge:HealthMonitoring:ReportInterval must be a positive duration " +
|
||||||
|
$"(was {options.ReportInterval}); it is used directly as a PeriodicTimer period.");
|
||||||
|
builder.RequireThat(options.OfflineTimeout > TimeSpan.Zero,
|
||||||
|
$"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " +
|
||||||
|
$"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence.");
|
||||||
|
builder.RequireThat(options.CentralOfflineTimeout > TimeSpan.Zero,
|
||||||
|
$"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " +
|
||||||
|
$"(was {options.CentralOfflineTimeout}).");
|
||||||
|
builder.RequireThat(
|
||||||
|
!(options.OfflineTimeout > TimeSpan.Zero
|
||||||
|
&& options.CentralOfflineTimeout > TimeSpan.Zero
|
||||||
|
&& options.CentralOfflineTimeout < options.OfflineTimeout),
|
||||||
|
$"ScadaBridge:HealthMonitoring:CentralOfflineTimeout ({options.CentralOfflineTimeout}) " +
|
||||||
|
$"must be >= OfflineTimeout ({options.OfflineTimeout}): the synthetic 'central' site has " +
|
||||||
|
"no heartbeat source and is fed only by the slower self-report loop, so it needs at " +
|
||||||
|
"least as much offline grace as a real site.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Reminder: do **not** swap to `builder.PositiveTimeSpan/MinCount/OneOf` — their wording differs
|
||||||
|
> from these bespoke messages and would break the existing tests. `ClusterOptionsValidator` has the
|
||||||
|
> most rules (SeedNodes≥2, strategy one-of, three positive-`TimeSpan`, cross-field heartbeat,
|
||||||
|
> `DownIfAlone`, `MinNrOfMembers`); apply the same invert-condition-keep-message recipe to each.
|
||||||
|
|
||||||
|
**Step — build + test (guard):**
|
||||||
|
Run: `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter FullyQualifiedName~OptionsValidator`
|
||||||
|
Expected: the four validators' existing tests pass unchanged.
|
||||||
|
|
||||||
|
**Step — commit:** `git commit -am "refactor: ScadaBridge validators onto OptionsValidatorBase (messages unchanged)"`
|
||||||
|
|
||||||
|
(Optional follow-on, separate task: collapse each module's `AddXxx` `Bind+ValidateOnStart+TryAddEnumerable`
|
||||||
|
into `AddValidatedOptions<T,TValidator>` where the binding shape matches — preserve HealthMonitoring's
|
||||||
|
idempotent registration called from three entry points. Verify each test still passes.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: ScadaBridge — `StartupValidator` → `ConfigPreflight`
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs` (re-implement body over `ConfigPreflight`) — or inline into `Program.cs:41` and delete the class.
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:41`
|
||||||
|
- Test (regression guard, MUST stay green unchanged): `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs`
|
||||||
|
|
||||||
|
> The final thrown message is **byte-identical** between `StartupValidator`
|
||||||
|
> (`"Configuration validation failed:\n - …"`) and `ConfigPreflight.ThrowIfInvalid()` — verified.
|
||||||
|
> The individual messages are bespoke and several are **cross-field** (GrpcPort≠RemotingPort,
|
||||||
|
> MetricsPort≠RemotingPort/GrpcPort, seed-node-port≠GrpcPort). `ConfigPreflight` has no
|
||||||
|
> `Add`/`RequireThat`; reproduce these via the `Require(key, predicate, reason)` escape hatch where
|
||||||
|
> the predicate **closes over** the other resolved values and ignores its passed argument, and
|
||||||
|
> `reason` is the exact tail so `$"{key} {reason}"` equals the original message.
|
||||||
|
|
||||||
|
**Recipe (preserve every message):**
|
||||||
|
- `RequireValue(key)` only where the original message is exactly `"{key} is required"`
|
||||||
|
(e.g. `ScadaBridge:Node:NodeHostname is required`).
|
||||||
|
- Everything else → `Require(key, pred, reason)`:
|
||||||
|
- `Require("ScadaBridge:Node:Role", raw => raw is "Central" or "Site", "must be 'Central' or 'Site'")`.
|
||||||
|
- `Require("ScadaBridge:Node:RemotingPort", raw => int.TryParse(raw, out var p) && p is >= 1 and <= 65535, "must be 1-65535")` — **do not** use `RequirePort` (its wording differs).
|
||||||
|
- `Require("ScadaBridge:Cluster:SeedNodes", _ => (seedNodes?.Count ?? 0) >= 2, "must have at least 2 entries")` (read `seedNodes` once via `.Get<List<string>>()`).
|
||||||
|
- Role-conditional blocks → `.When(role == "Central", p => { … })` / `.When(role == "Site", p => { … })`.
|
||||||
|
- Cross-field, value-ignoring predicate example:
|
||||||
|
`p.Require("ScadaBridge:Node:GrpcPort", _ => port != grpcPort, "must differ from RemotingPort")`.
|
||||||
|
- Seed-node loop: `foreach (var seed in seedNodes ?? []) p.Require("ScadaBridge:Cluster:SeedNodes", _ => SeedNodePort(seed) != grpcPort, $"entry '{seed}' must not target the gRPC port ({grpcPort}); seed nodes must reference Akka remoting ports");` (keep the private `SeedNodePort` helper).
|
||||||
|
|
||||||
|
Resolve `role`, `port`, `grpcPort` (default 8083), `metricsPort` (default 8084) with the **exact**
|
||||||
|
parse-or-default logic from the current `StartupValidator` before building the preflight, then end
|
||||||
|
with `.ThrowIfInvalid()`.
|
||||||
|
|
||||||
|
**Step — run the guard test (unchanged):**
|
||||||
|
Run: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter FullyQualifiedName~StartupValidatorTests`
|
||||||
|
Expected: PASS with no test edits — this is the byte-compatibility proof.
|
||||||
|
|
||||||
|
**Step — full ScadaBridge build + test:**
|
||||||
|
Run: `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx`
|
||||||
|
Expected: all green (four validators + `StartupValidatorTests`).
|
||||||
|
|
||||||
|
**Step — commit:** `git commit -am "refactor: ScadaBridge StartupValidator → ConfigPreflight (byte-compatible)"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification (all repos)
|
||||||
|
|
||||||
|
- `ZB.MOM.WW.Configuration` registration index → 200.
|
||||||
|
- Each repo: clean `dotnet restore` pulls `ZB.MOM.WW.Configuration 0.1.0` from Gitea.
|
||||||
|
- Each repo: `dotnet build` + `dotnet test` green on its `feat/adopt-zb-configuration` branch.
|
||||||
|
- No message-string drift anywhere except OtOpcUa's net-new validators.
|
||||||
|
- Open the three per-repo PRs (or finish per `superpowers-extended-cc:finishing-a-development-branch`).
|
||||||
|
- Update `components/configuration/GAPS.md` + the CLAUDE.md matrix to reflect actual adoption.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- DRY/YAGNI/TDD honored: net-new OtOpcUa code is test-first; migrations rely on existing tests as the regression guard.
|
||||||
|
- `scadaproj` itself is NOT a git repo — do not `git init` it. Commits happen inside each sister repo.
|
||||||
|
- Skills: `@superpowers-extended-cc:executing-plans`, `@superpowers-extended-cc:test-driven-development`, `@superpowers-extended-cc:verification-before-completion`.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-01-deploy-zb-configuration.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 11, "subject": "Task 1: Foundation — publish package + wire 3 consumers", "classification": "small", "status": "completed", "result": "Published ZB.MOM.WW.Configuration 0.1.0 to Gitea (was 404; now 200). nuget.config source-mapping + version pins on feat/adopt-zb-configuration in all 3 repos. Commits: MxGw 437ab65, OtOpcUa 0cbb82e, ScadaBridge 9bca6aa."},
|
||||||
|
{"id": 12, "subject": "Task 2: MxGateway — GatewayOptionsValidator → base", "classification": "standard", "status": "completed", "blockedBy": [11], "commit": "459a88b", "result": "Migrated to OptionsValidatorBase via RequireThat (messages byte-identical); AddGatewayConfiguration → AddValidatedOptions (+4 call sites). Tests 571/574 (3 pre-existing macOS failures). Spec ✅, code Approved-with-minors."},
|
||||||
|
{"id": 13, "subject": "Task 3: OtOpcUa — net-new LdapOptionsValidator", "classification": "standard", "status": "completed", "blockedBy": [12], "commit": "f35ebd7", "result": "New LdapOptionsValidator; Program.cs:99 → AddValidatedOptions(config,'Ldap') — behaviour-preserving per user decision A. FLAG: OtOpcUa LDAP binds nonexistent sections (real config = Security:Ldap); recorded as memory otopcua-ldap-config-section-mismatch. 4/4 new tests; build 0/0."},
|
||||||
|
{"id": 14, "subject": "Task 4: OtOpcUa — OpcUa validator + DI routing", "classification": "high-risk", "status": "completed", "blockedBy": [12], "commit": "88e773a", "result": "New OpcUaApplicationHostOptionsValidator; AddValidatedOptions(config,'OpcUa') in hasDriver block; hosted service now consumes IOptions (dead _configuration removed). 4/4 new tests; build 0/0. Spec ✅, code Approved-with-minors."},
|
||||||
|
{"id": 15, "subject": "Task 5: ScadaBridge — 4 validators → base", "classification": "standard", "status": "completed", "blockedBy": [13, 14], "commit": "aac59c9", "result": "Cluster/Security/HealthMonitoring/AuditLog → OptionsValidatorBase via RequireThat (no primitives; messages verbatim). DI untouched (AddValidatedOptions collapse deferred). 33/33 validator tests unchanged. Spec ✅, code Approved-with-minors (De Morgan readability nits)."},
|
||||||
|
{"id": 16, "subject": "Task 6: ScadaBridge — StartupValidator → ConfigPreflight", "classification": "high-risk", "status": "completed", "blockedBy": [13, 14], "commit": "6dbbc7a", "result": "StartupValidator body re-implemented over ConfigPreflight (Require escape-hatch for bespoke + cross-field rules; default int.TryParse + IsNullOrEmpty preserved). StartupValidatorTests 46/46 UNCHANGED (byte-compat proof). Spec ✅, code Approved-with-minors."}
|
||||||
|
],
|
||||||
|
"deferred": [
|
||||||
|
"ScadaBridge: collapse module AddXxx → AddValidatedOptions (DI simplification; preserve HealthMonitoring idempotent registration).",
|
||||||
|
"MxGateway pre-existing (not regressions): Ldap:Port allows >65535; AddIfInvalidPath doesn't catch IOException.",
|
||||||
|
"OtOpcUa pre-existing bug (flagged + memory): LdapOptions binds Security:Ldap nowhere; DevStubMode never applies — separate behaviour-changing fix.",
|
||||||
|
"Cosmetic: De Morgan predicate comments (ScadaBridge validators); vestigial `var options = _options` in OtOpcUaServerHostedService."
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-01"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user