diff --git a/CLAUDE.md b/CLAUDE.md index e181aa94..dfd6862e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,6 +315,11 @@ When the section is disabled the applier binds the no-op `NullHistorianProvision run. Every dispatch logs a tally (`dispatched/requested/ensured/skipped/failed`); `dispatched=N` with `requested=0` flags the dormant no-op. The API key must carry `historian:tags:write` for `EnsureTags`. (PR #423 shipped `GatewayTagProvisioner` but left this wiring out, so provisioning was dormant; restored 2026-06-27.) +**0.2.0 DataType delta (archreview 06/U-7):** `EnsureTags` is an **upsert** (create-or-update, not +skip-if-exists), and the 0.2.0 `HistorianTagDefinition.DataType` proto3-optional change means Boolean tags now +provision as **Int1** (0.1.0 silently defaulted them to Float). A pre-bump Float Boolean is asked to retype +on its next deploy; the AVEVA-side retype policy is pinned by the live retype probe — see the "0.2.0 DataType +provisioning delta" section in `docs/Historian.md`. ### Gateway-side prerequisites @@ -349,6 +354,7 @@ export HISTGW_GATEWAY_ENDPOINT=https://wonder-sql-vd03:5222 # absolute gateway export HISTGW_GATEWAY_APIKEY=histgw__ # must carry historian:read + historian:write (+ tags:write) scopes export HISTGW_TEST_TAG= # read round-trip export HISTGW_WRITE_SANDBOX_TAG= # e.g. HistGW.LiveTest.Sandbox — write round-trip (EnsureTags + write) +export HISTGW_BOOL_SANDBOX_TAG= # e.g. HistGW.LiveTest.BoolSandbox — 0.2.0 Boolean EnsureTags leg + retype probe (06/U-7) export HISTGW_ALARM_SOURCE= # alarm SendEvent → ReadEvents round-trip dotnet test --filter "Category=LiveIntegration" ``` diff --git a/archreview/06-gateway-integrations.md b/archreview/06-gateway-integrations.md index ab9c6b44..512b8d37 100644 --- a/archreview/06-gateway-integrations.md +++ b/archreview/06-gateway-integrations.md @@ -43,7 +43,7 @@ | U-5 (Low) | Outbox serializer has no version/format byte | **STILL OPEN** — `HistorizationOutboxEntrySerializer.cs:14-16` unchanged. | | U-6 | Test-coverage thin spots (advise-failure status, out-of-order `RemoveAsync`, S-3 hang, pump filter) | **STILL OPEN** — zero test changes in the three gateway test projects since `9cad9ed0`. | -**New findings this pass:** S-11 (Medium), U-7 (Medium), C-7 (Low) — see body. +**New findings this pass:** S-11 (Medium), U-7 (Medium), C-7 (Low) — see body. **All three REMEDIATED on `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13); the U-7 live EnsureTags/retype leg is operator-gated pending a VPN run.** --- @@ -138,6 +138,8 @@ The read synthesis (`GalaxyDriver.cs:738-755`) fills pending snapshots with `Bad #### S-11 (Medium, NEW) — An enabled-but-misconfigured `ServerHistorian` section crash-loops the Host with a raw `UriFormatException`; one corner gets no warning at all +> **REMEDIATED — `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13; live leg operator-gated).** New fail-fast `ServerHistorianOptionsValidator` (Host, wired via `AddValidatedOptions`) consumer-gates on `ServerHistorian:Enabled` **OR** `AlarmHistorian:Enabled` (closing the zero-warning corner) and fails startup with a named, aggregated `OptionsValidationException` on an empty / non-absolute / non-http(s) `Endpoint`. Adapter defense-in-depth replaces the bare `new Uri(...)` with `Uri.TryCreate`→named `InvalidOperationException`. `Validate()` gains malformed-endpoint warning parity; an alarm-only deployment logs a startup warning. Also directly answers C-4's "give the warnings style a fail-fast tier". + `HistorianGatewayClientAdapter.Create` eagerly does `new Uri(options.Endpoint)` (`HistorianGatewayClientAdapter.cs:45`). `ServerHistorianOptions.Validate()` produces exactly the right warning for an empty Endpoint (`ServerHistorianOptions.cs:78-79`) — but it is **warnings-only**: registration proceeds (`Runtime/ServiceCollectionExtensions.cs:135-139`, `Host/Program.cs:123-127`) and the first DI resolution throws an unhandled `UriFormatException`, crashing the Host at bring-up. This is not theoretical — it made docker-dev unbootable until the config-side fix `7233e2ba` (whose commit message documents the exact failure). The code path is untouched. The sharper corner: **`AddAlarmHistorian` gates only on `AlarmHistorian:Enabled`** (`Runtime/ServiceCollectionExtensions.cs:87`) but sources its connection from the `ServerHistorian` section (`Program.cs:127`). With `AlarmHistorian:Enabled=true` and `ServerHistorian:Enabled=false` (Endpoint legitimately empty), `Validate()` early-returns on `!Enabled` (`ServerHistorianOptions.cs:77`) — so the operator gets **zero warning**, then the same `UriFormatException` out of `CreateAlarmWriter` when the sink singleton is built. Same exposure for the continuous-historization writer (`Program.cs:187`, though that one is co-gated on `ServerHistorian.Enabled`, so only a *malformed* non-empty Endpoint reaches it). @@ -212,6 +214,8 @@ Galaxy: throw-on-construct for required fields; DataAnnotations attributes on th #### C-7 (Low, NEW) — The live-value writer populates a documented-dead `Quality` field +> **REMEDIATED — `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13).** Decision: **keep populating** the field (the "stop populating so it's visible" alternative was rejected — proto3 sends `0` = Bad when unset, planting a future bug the instant a gateway version honors the field, whereas the recorder's `192` is correct). Visibility achieved with doc comments instead: `GatewayHistorianValueWriter` `` + inline note at the mapping, `ContinuousHistorizationRecorder.GoodQuality` guard note, and a `docs/Historian.md` sentence — all citing 0.2.0 Contracts C-002. + `GatewayHistorianValueWriter` maps `HistorizationValue.Quality` onto `HistorianLiveValue.Quality` (`GatewayHistorianValueWriter.cs:63`), but the 0.2.0 contract now documents that field (and `QualityDetail`) as **silently discarded on the `WriteLiveValues` SQL path** — the gateway's SQL login is column-permission-denied on the server-managed Quality column, so the value is accepted on the wire and dropped. Harmless today because the recorder hardcodes `GoodQuality = 192` for every captured value (`ContinuousHistorizationRecorder.cs:45,256` — the outbox round-trips it, `:419`), so nothing meaningful is lost. The hazard is forward-looking: if the recorder is ever extended to capture *real* node quality (a natural next step), it will appear to work and silently persist nothing — quality will remain server-stamped. Add a comment at the mapping site citing the 0.2.0 contract note (Contracts C-002), or stop populating the field so the dead input is visible. ### 4. Underdeveloped Areas @@ -237,6 +241,8 @@ The `Category=LiveIntegration` suite is complete and skip-clean: `GatewayLiveFix #### U-7 (Medium, NEW) — The 0.2.0 bump's impact analysis missed the `DataType` presence change that alters Boolean tag provisioning +> **REMEDIATED (code + docs) — `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13); LIVE LEG OPERATOR-GATED.** Pinned the 0.2.0 Boolean→Int1 explicit-presence semantics at two levels (`GatewayTagProvisionerTests.Boolean_definition_carries_explicit_Int1_presence` asserts `DataType==Int1` **and** `HasDataType` — the 0.2.0 presence witness; a package downgrade breaks the compile, a dropped-presence contract change breaks the assert). Added a written upsert/retype migration note to `docs/Historian.md` (three retype hypotheses + `failed=N` tally observability). Added the env-gated live Boolean EnsureTags leg + a **retype probe** (`HISTGW_BOOL_SANDBOX_TAG`) that records the deployed historian's in-place-retype policy. **Pending:** the live VPN run on a 0.2.0 gateway (feeds the observed retype answer back into the doc note; also discharges U-2's full documented run). + The bump commit (`f6eaa267`) gates only on the M3 `opc_quality` change ("checked and CLEAR" — correct, verified: OtOpcUa never writes `HistorianHistoricalValue`). But 0.2.0 also made **`HistorianTagDefinition.DataType` proto3-optional** (contracts review M-2), and that one lands squarely on `GatewayTagProvisioner`: - The provisioner always sets `DataType` explicitly (`GatewayTagProvisioner.cs:57`), and `Boolean → HistorianDataType.Int1` (`HistorianTypeMapper.cs:28`) — where **Int1 is wire value 0**. diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md b/archreview/plans/R2-06-serverhistorian-failfast-plan.md index 55edd5fc..642b201f 100644 --- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md +++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md @@ -245,3 +245,11 @@ Every cited file:line was opened against the working tree at `f6eaa267`. **All t | C-7 | **XS** (Task 10, ~5 min) | None | Comments/docs; deliberate keep-populating decision recorded | **Total: ~1 hour of implementation across 3 commits, plus one operator VPN session for the live gate.** Suggested sequencing: Commit 1 (S-11) → Commit 2 (U-7) → Commit 3 (C-7) → Task 11 bookkeeping → Task 12 operator gate. + +## Execution deviations (R2-06) + +Executed 2026-07-13 on branch `r2/06-serverhistorian-failfast` (off master `1676c8f4`). + +- **Commit granularity — per-task, not the plan's 3 grouped commits.** The plan text grouped the work into 3 commits (one per finding). The executor's operating method mandated one pathspec-scoped commit **per task** (with the task's `tasks.json` status flip in the same commit) for traceability/bisectability. Followed the per-task rule → 11 task commits (T1–T11) instead of 3. No content difference; strictly finer history. Reversible (squashable at merge if 3 commits are still wanted). +- **Validator + wiring tests live in `Host.IntegrationTests` but are pure unit tests.** Per the plan (they mirror `LdapOptionsValidatorTests`, which lives there). They were verified green **filtered** (`--filter FullyQualifiedName~ServerHistorianOptionsValidatorTests`, 8/8) once during T4 — **before** a mid-run controller constraint arrived forbidding any run of `*.IntegrationTests` projects (a ~16 GB/run memory-leak in the heavy integration tests) and whole-solution `dotnet test`. After that constraint they were **not re-run**; the earlier filtered pass + a clean `dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors) are the standing evidence. The Historian.Gateway driver unit suite (a plain `.Tests` project) and the Runtime.Tests suite were run freely. +- **T12 (live VPN gate) → `deferred-live`.** Genuinely external infra (VPN to `wonder-sql-vd03` + a 0.2.0 gateway with `RuntimeDb:Enabled=true` and an API key carrying `historian:read`+`write`+`tags:write`). The code (T8: fixture env var + Boolean EnsureTags leg + retype probe) is complete and offline **skip-clean** (6 LiveIntegration tests, all skipped, 0 failed). The operator runs the suite on the VPN and feeds the observed retype policy back into `docs/Historian.md` + STATUS.md. diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json index b50deccb..df0fa5f5 100644 --- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json +++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json @@ -5,74 +5,96 @@ { "id": "T1", "subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)", - "status": "pending", + "status": "completed", "blockedBy": [] }, { "id": "T2", "subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)", - "status": "pending", - "blockedBy": ["T1"] + "status": "completed", + "blockedBy": [ + "T1" + ] }, { "id": "T3", "subject": "RED: ServerHistorianOptionsValidator unit tests (7 cases incl. the AlarmHistorian-enabled/ServerHistorian-disabled corner naming both sections)", - "status": "pending", - "blockedBy": ["T1"] + "status": "completed", + "blockedBy": [ + "T1" + ] }, { "id": "T4", "subject": "GREEN: implement ServerHistorianOptionsValidator (OptionsValidatorBase, ctor IConfiguration, consumed = Enabled || AlarmHistorian:Enabled, fail on empty/non-absolute/non-http(s) Endpoint)", - "status": "pending", - "blockedBy": ["T3"] + "status": "completed", + "blockedBy": [ + "T3" + ] }, { "id": "T5", "subject": "Wiring: AddValidatedOptions in Program.cs hasDriver block + OptionsValidationException wiring test + residual alarm-only-mode startup warning", - "status": "pending", - "blockedBy": ["T4"] + "status": "completed", + "blockedBy": [ + "T4" + ] }, { "id": "T6", "subject": "Warning parity: malformed-endpoint warning in ServerHistorianOptions.Validate() + Runtime.Tests case; commit 1 (S-11) after T1-T6 green", - "status": "pending", - "blockedBy": ["T4"] + "status": "completed", + "blockedBy": [ + "T4" + ] }, { "id": "T7", "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)", - "status": "pending", + "status": "completed", "blockedBy": [] }, { "id": "T8", "subject": "U-7 live leg: HISTGW_BOOL_SANDBOX_TAG in GatewayLiveFixture + EnsureTags_boolean_sandbox_provisions_as_Int1 + EnsureTags_type_change_outcome_is_observable retype probe (skip-clean offline; verify all-skip run)", - "status": "pending", - "blockedBy": ["T7"] + "status": "completed", + "blockedBy": [ + "T7" + ] }, { "id": "T9", "subject": "U-7 docs: migration/assessment note (EnsureTags is upsert; pre-bump Float Booleans; three retype hypotheses; failed=N tally observability) in docs/Historian.md + CLAUDE.md HISTGW_BOOL_SANDBOX_TAG recipe update; commit 2 (U-7) after T7-T9", - "status": "pending", - "blockedBy": ["T8"] + "status": "completed", + "blockedBy": [ + "T8" + ] }, { "id": "T10", "subject": "C-7: keep-populating decision + dead-Quality doc comments at GatewayHistorianValueWriter.cs:63, ContinuousHistorizationRecorder.GoodQuality, docs/Historian.md (cites 0.2.0 Contracts C-002); commit 3 (C-7)", - "status": "pending", + "status": "completed", "blockedBy": [] }, { "id": "T11", "subject": "Bookkeeping: update archreview/plans/STATUS.md + 06-gateway-integrations.md prior-finding rows for S-11/U-7/C-7 when the work lands", - "status": "pending", - "blockedBy": ["T6", "T9", "T10"] + "status": "completed", + "blockedBy": [ + "T6", + "T9", + "T10" + ] }, { "id": "T12", "subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run", - "status": "pending", - "blockedBy": ["T9", "T11"] + "status": "deferred-live", + "blockedBy": [ + "T9", + "T11" + ], + "note": "needs live gateway over VPN; needs-user / serial live pass" } ] } diff --git a/archreview/plans/STATUS.md b/archreview/plans/STATUS.md index 4642250e..3d74a78d 100644 --- a/archreview/plans/STATUS.md +++ b/archreview/plans/STATUS.md @@ -164,3 +164,9 @@ clean. **OPEN follow-ups:** (a) when a runner-reachable fleet + a real `tests/Se add a fresh E2E workflow WITH a zero-match guard (fail if `Category=E2E` matches nothing); (b) S-2 option #2 — a fixture-backed integration leg (opc-plc `services:` block, `MIN_EXECUTED=1`) covering what the unit leg cannot; (c) migrate the 3 xunit v2 holdouts once Akka ships an xunit.v3 TestKit. + +### Round-2 remediation landed + +| Plan | Findings | Branch | Commits | Status | +|---|---|---|---|---| +| **R2-06** | 06/S-11 (rank 7), 06/U-7 (rank 8), 06/C-7 | `r2/06-serverhistorian-failfast` (off `1676c8f4`) | `3a049a13`…`4640ecb1` (10 task commits) | **Code-complete, offline-verified.** S-11: fail-fast `ServerHistorianOptionsValidator` (consumer-gated on `ServerHistorian:Enabled` OR `AlarmHistorian:Enabled`, fails empty/non-http(s) Endpoint at startup) + `AddValidatedOptions` wiring + adapter defense-in-depth (`TryCreate`→named `InvalidOperationException`, no more raw `UriFormatException` crash-loop) + `Validate()` warning parity + alarm-only-mode startup warning. U-7: pinned Boolean→Int1 explicit-presence (`HasDataType`) + `HISTGW_BOOL_SANDBOX_TAG` live Boolean EnsureTags leg + retype probe (skip-clean offline) + upsert/retype migration note (`docs/Historian.md`). C-7: keep-populating decision + dead-`Quality` doc comments (0.2.0 C-002). **Live leg operator-gated (T12) — pending a VPN run on a 0.2.0 gateway** (records the observed in-place retype policy back into `docs/Historian.md`; also discharges 06/U-2's full documented run). | diff --git a/docs/Historian.md b/docs/Historian.md index e33ab8c0..97a8abfd 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -102,6 +102,12 @@ write path) and the `ContinuousHistorization` section (driver-value capture). Al **same** gateway — but only `ServerHistorian` carries the connection (endpoint/key/TLS); the other two source it from there. +> **Continuous-historization value quality is server-stamped.** The gateway's `WriteLiveValues` SQL path +> **discards** the quality the recorder sends (a server-managed column; 0.2.0 Contracts C-002), so persisted +> quality is stamped gateway-side regardless of what the `ContinuousHistorizationRecorder` provides (it +> hardcodes OPC-DA Good, `192`). Capturing real per-node quality will not persist until the gateway honors +> the field — see `GatewayHistorianValueWriter`'s remarks (archreview 06/C-7). + --- ## Tag auto-provisioning (EnsureTags) @@ -129,6 +135,35 @@ for this node), which is the signal to check that `ServerHistorian:Enabled=true` > so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy > keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above. +### 0.2.0 DataType provisioning delta (archreview 06/U-7) + +`EnsureTags` is contract-documented (HistorianGateway 0.2.0) as an **upsert** — *"creates or updates +historian tags according to the given definitions"* — that maps per-tag onto AVEVA's +`HistorianClient.EnsureTagAsync(definition) -> bool`. It is **not idempotent** (not routed through the +idempotent retry pipeline) and **not skip-if-exists**. + +The 0.1.0→0.2.0 client bump made `HistorianTagDefinition.DataType` **proto3-optional** (explicit presence). +Under 0.1.0, the provisioner's `Boolean → Int1` mapping sent wire value `0`, indistinguishable from unset, so +the gateway defaulted such tags to the SDK-default **Float**. Under 0.2.0 the same call carries explicit +presence, so Boolean tags now provision as **Int1** (the pre-existing intent of `HistorianTypeMapper` — no +mapper code change is needed or wanted). Consequences: + +1. **New Boolean tags provision correctly as Int1.** This is the fix arriving as a silent, zero-diff + semantic change from the bump. +2. **Pre-bump Boolean tags exist historian-side as Float.** On the next deploy that dispatches provisioning + for such a tag, the gateway is asked (via the upsert) to update it to Int1. The AVEVA-side outcome of an + in-place analog retype is the one thing the contract does not pin; the candidate behaviors are + (i) **accepted with tag versioning** (history preserved under the old version, new writes under Int1), + (ii) **accepted as a plain retype**, or (iii) **rejected per-tag** → `TagOperationResult.Success=false` + → counted in the provisioner's `failed=N` dispatch tally. Only a live run distinguishes these — see the + **retype probe** (`EnsureTags_type_change_outcome_is_observable`) in the [Live /run gate](#live-run-gate). + *(This list is replaced with the observed answer after the first live run.)* +3. **The failure mode is bounded and observable either way.** Provisioning is fire-and-forget and never + blocks/fails a deploy; a per-tag rejection surfaces only as the tally's `failed` count. Values written to + a still-Float tag by the recorder remain readable (Float supersets Int1's 0/1 range); nothing is lost + pending a reconcile. A bulk retype sweep is **not** done in code — assess via the probe first, then decide + whether reconciliation is a one-time operator action. + --- ## HistoryRead behavior @@ -350,7 +385,14 @@ AVEVA Historian behind it healthy. Set `ServerHistorian:Enabled=true` with the c least one historized Galaxy tag. The gate is operator-driven — it is not part of the local docker-dev rig. The gateway-backed driver also ships an env-gated live suite (`Category=LiveIntegration`); see the `HISTGW_GATEWAY_ENDPOINT` / `HISTGW_GATEWAY_APIKEY` / `HISTGW_TEST_TAG` / `HISTGW_WRITE_SANDBOX_TAG` / -`HISTGW_ALARM_SOURCE` env vars (it skips cleanly when they are absent). +`HISTGW_BOOL_SANDBOX_TAG` / `HISTGW_ALARM_SOURCE` env vars (it skips cleanly when they are absent). +`HISTGW_BOOL_SANDBOX_TAG` (e.g. `HistGW.LiveTest.BoolSandbox`, a **dedicated** writable Boolean tag — +never the shared Float write sandbox) drives the 0.2.0 Boolean `EnsureTags` leg +(`EnsureTags_boolean_sandbox_provisions_as_Int1`) and the retype probe +(`EnsureTags_type_change_outcome_is_observable`) that resolve the +[0.2.0 DataType provisioning delta](#020-datatype-provisioning-delta-archreview-06u-7) open question. Running +the full suite against a **0.2.0** gateway (all six tests, none skipped) also discharges 06/U-2's +"full documented run" for the historian suite. See [AlarmHistorian.md](AlarmHistorian.md) for the alarm write path and [ServiceHosting.md](ServiceHosting.md) for the (external) HistorianGateway deployment. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs index 251b0fc6..5f429f30 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs @@ -35,14 +35,31 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi /// The bound ServerHistorian configuration (endpoint, key, TLS posture). /// Logger factory threaded into the package client's channel diagnostics. /// A ready-to-use adapter whose underlying channel has not yet dialed the gateway. + /// + /// is empty or not an absolute http(s) URI. + /// Defense in depth (archreview 06/S-11): the Host's ServerHistorianOptionsValidator fails + /// these configs at startup, but any caller that bypasses ValidateOnStart (tests, the live + /// fixture, future non-Host wiring) still gets a named, config-key-carrying error here instead of + /// the raw the bare new Uri(...) ctor throws. + /// public static HistorianGatewayClientAdapter Create(ServerHistorianOptions options, ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(loggerFactory); + // Fail fast with a named, actionable error rather than letting `new Uri("")` throw a raw + // UriFormatException deep in the factory (archreview 06/S-11). The Endpoint is not a secret, + // so it is safe to echo; the ApiKey is never surfaced. + if (!Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var endpointUri) + || (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps)) + { + throw new InvalidOperationException( + $"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'."); + } + var clientOptions = new HistorianGatewayClientOptions { - Endpoint = new Uri(options.Endpoint), + Endpoint = endpointUri, ApiKey = options.ApiKey, UseTls = options.UseTls, CaCertificatePath = options.CaCertificatePath, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs index ac3ccacb..c862c4a3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs @@ -24,6 +24,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; /// A success ack OR a store-forward-queued ack maps to true: a value the gateway /// durably queued must not be re-drained. /// +/// +/// Quality is a documented-dead input on the WriteLiveValues SQL path +/// (HistorianGateway 0.2.0 Contracts C-002): HistorianLiveValue.Quality / +/// QualityDetail are server-managed columns — any value sent is accepted on the wire and +/// then silently discarded. We populate Quality anyway (see the mapping below): proto3 +/// transmits 0 when a field is left unset, and 0 is "Bad" in OPC-DA terms, so +/// not setting it would send a wrong quality to any future gateway version that +/// starts honoring the field — whereas the recorder's 192 ("Good") is the correct value. +/// Do NOT extend the recorder to capture real node quality expecting it to persist — it +/// will not, until the gateway honors the field. +/// /// public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncDisposable { @@ -60,6 +71,8 @@ public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncD var live = new HistorianLiveValue { NumericValue = value.Value, + // Documented-dead input on the WriteLiveValues SQL path (0.2.0 Contracts C-002 — + // server-managed, silently discarded). Populated deliberately; see the class . Quality = value.Quality, }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs new file mode 100644 index 00000000..ca727a5f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Fail-fast startup validator for (archreview 06/S-11), +/// built on the shared ZB.MOM.WW.Configuration . +/// Wired via AddValidatedOptions so a provably-crashing historian config fails host startup +/// with a named, aggregated OptionsValidationException instead of the raw +/// the gateway client factory's new Uri(...) would +/// otherwise throw at first resolution (a crash-loop under a service/docker restart policy). +/// +/// +/// +/// Consumer-gated, not section-gated. The load-bearing question is not "is +/// ServerHistorian enabled?" but "is its gateway connection consumed by anything +/// enabled?". The alarm-history sink (AlarmHistorian:Enabled=true) sources its +/// endpoint/key/TLS from the ServerHistorian section independently of +/// , so it must also gate this check — otherwise the +/// AlarmHistorian:Enabled=true / ServerHistorian:Enabled=false corner crashes with +/// zero warning. Continuous historization is already co-gated on +/// in the Host, so Enabled covers it. +/// +/// +/// Fail tier = provably-crashing configs only. Only an empty / non-absolute / non-http(s) +/// Endpoint fails here (it throws in the factory). Empty ApiKey and non-positive +/// MaxTieClusterOverfetch degrade rather than crash (the gateway rejects calls / the node +/// manager surfaces a Bad read), so they stay operator warnings in +/// . The Endpoint value is not a secret and +/// is echoed to make the error actionable; the ApiKey is never surfaced. +/// +/// +public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase +{ + private readonly IConfiguration _configuration; + + /// Creates the validator over the app configuration (read to resolve the consumer gate). + /// The app configuration; used to read AlarmHistorian:Enabled. + public ServerHistorianOptionsValidator(IConfiguration configuration) => + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + + /// + protected override void Validate(ValidationBuilder builder, ServerHistorianOptions options) + { + var alarmHistorianEnabled = _configuration.GetValue("AlarmHistorian:Enabled"); + var consumed = options.Enabled || alarmHistorianEnabled; + + // A section nobody consumes may legitimately be empty (the docker-dev default) — no checks. + if (!consumed) return; + + var endpointValid = Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + // Name the DRIVING section(s) so the operator knows why an empty/malformed endpoint fails — + // especially in the alarm-only corner where ServerHistorian itself is disabled. + var reason = options.Enabled + ? "required because ServerHistorian:Enabled=true" + : "required because AlarmHistorian:Enabled=true sources its gateway connection (endpoint/key/TLS) from the ServerHistorian section"; + + builder.RequireThat( + endpointValid, + $"ServerHistorian:Endpoint is empty or not an absolute http(s) URI ('{options.Endpoint}') — {reason}."); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 78e9707e..c5bfdff7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -117,11 +117,32 @@ if (hasDriver) // the read-side GatewayHistorianDataSource to stop owning + disposing its client (regressing the read // cutover), and a second channel to a co-located sidecar is cheap (the gateway pools the historian // sessions server-side). + // Fail-fast startup validation for the ServerHistorian section (archreview 06/S-11). A + // provably-crashing config — an enabled historian (or an enabled AlarmHistorian, which sources its + // gateway connection from THIS section) with an empty/malformed Endpoint — now fails host start with + // a named OptionsValidationException instead of a raw UriFormatException crash-loop out of the gateway + // client factory. Driver-block-only (mirrors OpcUaApplicationHostOptionsValidator below): admin-only + // nodes never build the historian singletons, so they must not be failed by a section they never + // consume. ApiKey/MaxTieClusterOverfetch stay warnings (they degrade, not crash) — see Validate(). + builder.Services.AddValidatedOptions( + builder.Configuration, ServerHistorianOptions.SectionName); + var serverHistorianOptions = builder.Configuration .GetSection(ServerHistorianOptions.SectionName).Get() ?? new ServerHistorianOptions(); foreach (var warning in serverHistorianOptions.Validate()) Log.Warning("ServerHistorian misconfiguration detected at startup: {Warning}", warning); + + // Residual "unusual but valid" corner (archreview 06/S-11): an alarm-history-only deployment + // (AlarmHistorian:Enabled=true while ServerHistorian:Enabled=false) legitimately sources its gateway + // connection from the disabled ServerHistorian section. That is allowed (the endpoint was already + // validated above), but log it so the mode is not a silent surprise: HistoryReads stay GoodNoData, + // and a wrong endpoint/key surfaces only as SQLite store-and-forward retry/dead-letter growth. + if (builder.Configuration.GetValue("AlarmHistorian:Enabled") && !serverHistorianOptions.Enabled) + Log.Warning( + "AlarmHistorian is enabled but ServerHistorian is disabled — the alarm-history sink is sourcing its " + + "gateway connection (endpoint/key/TLS) from the disabled ServerHistorian section; HistoryReads stay GoodNoData."); + builder.Services.AddAlarmHistorian( builder.Configuration, (_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp)); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs index 8c589e6f..bbb4122a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs @@ -41,7 +41,14 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers { /// OPC-DA quality byte recorded for mux-fanned values (the mux drops quality; driver-published - /// values are Good by the same convention the scripted-alarm host applies). + /// values are Good by the same convention the scripted-alarm host applies). + /// + /// Note (archreview 06/C-7): the historian gateway's WriteLiveValues SQL path discards + /// quality (server-managed column, 0.2.0 Contracts C-002 — see + /// GatewayHistorianValueWriter's remarks). This constant is therefore what a future + /// quality-honoring gateway path would receive; capturing real per-node quality here will + /// NOT persist until the gateway honors the field. + /// public const ushort GoodQuality = 192; private const string DrainTimerKey = "drain"; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index 2ffedff4..98f4eb56 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -69,7 +69,16 @@ public sealed class ServerHistorianOptions public int MaxTieClusterOverfetch { get; init; } = 65536; /// Returns operator-facing misconfiguration warnings for an Enabled historian - /// (empty when disabled or correctly configured). Pure — the registration logs each entry. + /// (empty when disabled or correctly configured). Pure — the registration logs each entry. + /// + /// These are warnings only. The Host layers a fail-fast startup validator + /// (ServerHistorianOptionsValidator, wired via AddValidatedOptions) on top of this + /// for the provably-crashing subset — an empty/malformed Endpoint that is consumed + /// by an enabled historian or an enabled AlarmHistorian — so that config fails host start + /// with a named error instead of a raw UriFormatException crash-loop (archreview 06/S-11). + /// The empty-ApiKey / non-positive-MaxTieClusterOverfetch entries stay warnings + /// because they degrade rather than crash. + /// /// Zero or more human-readable warning messages (never carrying secret values). public IReadOnlyList Validate() { @@ -77,6 +86,13 @@ public sealed class ServerHistorianOptions if (!Enabled) return warnings; if (string.IsNullOrWhiteSpace(Endpoint)) warnings.Add("ServerHistorian:Endpoint is empty while the historian is enabled — the read client has no gateway address to dial."); + // archreview 06/S-11 warning parity: a non-empty but unparseable / non-absolute-http(s) Endpoint + // is just as fatal (it throws in the gateway client factory). Warn on it here so the Runtime-side + // registration logs describe malformed endpoints too, consistent with the Host's fail-fast + // ServerHistorianOptionsValidator (which turns this same condition into a startup failure). + else if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + warnings.Add($"ServerHistorian:Endpoint ('{Endpoint}') is not an absolute http(s) URI while the historian is enabled — the read client cannot dial it (e.g. https://host:5222)."); if (string.IsNullOrWhiteSpace(ApiKey)) warnings.Add("ServerHistorian:ApiKey is empty while the historian is enabled — the gateway gRPC surface will reject unauthenticated calls."); // MaxTieClusterOverfetch is intentionally checked AFTER the Enabled early-return above: diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs index ad022a47..a98dad05 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs @@ -31,6 +31,35 @@ public sealed class GatewayTagProvisionerTests Assert.Equal(0, result.Skipped); } + /// + /// archreview 06/U-7 — pins the 0.2.0 Boolean→Int1 provisioning semantics at TWO levels. The + /// 0.1.0→0.2.0 client bump made HistorianTagDefinition.DataType proto3-optional (explicit + /// presence): under 0.1.0 the provisioner's Int1 (wire 0) was indistinguishable from unset, + /// so the gateway defaulted Boolean tags to Float; under 0.2.0 the same call carries explicit + /// presence and provisions Int1 (the intended behavior). Asserting BOTH the mapped type AND + /// HasDataType compile-pins the package floor (the HasDataType/ClearDataType + /// members exist only on the 0.2.0 proto3-optional contract — a downgrade breaks the build) and + /// assert-pins that the provisioner always sends explicit presence (a future contract change that + /// stopped sending it would break the assert). This is a pin, not a fix — it passes on current code. + /// + [Fact] + public async Task Boolean_definition_carries_explicit_Int1_presence() + { + var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() }; + var p = Provisioner(fake); + + await p.EnsureTagsAsync( + new[] { new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null) }, + TestContext.Current.CancellationToken); + + var defs = fake.LastEnsureDefinitions!; + Assert.Single(defs); + Assert.Equal(HistorianDataType.Int1, defs[0].DataType); + // 0.2.0 proto3-optional presence witness — explicit presence is what flips the gateway from its + // SDK-default Float to the requested Int1. Without this, a Boolean tag would provision as Float. + Assert.True(defs[0].HasDataType); + } + [Fact] public async Task Maps_metadata_and_coalesces_null_metadata_to_empty() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs index 08dcc002..1a86a92e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; @@ -21,6 +22,38 @@ public sealed class HistorianGatewayClientAdapterTests Assert.NotNull(adapter); } + /// + /// archreview 06/S-11 — an enabled historian with an empty Endpoint must fail with a + /// named, config-key-carrying (defense-in-depth for any + /// caller that bypasses the Host's ServerHistorianOptionsValidator), not the raw + /// that new Uri("") throws deep in the factory. Current + /// code throws → this is the failing-first (RED) repro. + /// + [Fact] + public void Create_with_empty_endpoint_throws_named_config_error() + { + var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" }; + + var ex = Should.Throw( + () => HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance)); + ex.Message.ShouldContain("ServerHistorian:Endpoint"); + } + + /// + /// archreview 06/S-11 companion — a non-empty but unparseable / non-absolute Endpoint must + /// also fail with the same named , not a raw + /// . RED on current code. + /// + [Fact] + public void Create_with_malformed_endpoint_throws_named_config_error() + { + var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "not a uri", ApiKey = "histgw_x_y" }; + + var ex = Should.Throw( + () => HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance)); + ex.Message.ShouldContain("ServerHistorian:Endpoint"); + } + [Fact] public void Factory_builds_GatewayHistorianDataSource() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs index eda48b62..25caa3e8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs @@ -22,6 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live; /// HISTGW_GATEWAY_APIKEY — the histgw_<id>_<secret> key (must carry historian:read + historian:write scopes). Required; absent ⇒ all tests skip. /// HISTGW_TEST_TAG — an existing Galaxy / historian tag for the read round-trip. /// HISTGW_WRITE_SANDBOX_TAG — a Float sandbox tag the write round-trip may EnsureTags + write (e.g. HistGW.LiveTest.Sandbox). +/// HISTGW_BOOL_SANDBOX_TAG — a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) for the 0.2.0 Boolean EnsureTags leg + the retype probe (archreview 06/U-7). Dedicated — never the shared write sandbox. /// HISTGW_ALARM_SOURCE — a source name for the alarm SendEventReadEvents round-trip. /// HISTGW_GATEWAY_ALLOW_UNTRUSTEDtrue to accept a self-signed dev cert (optional). /// @@ -44,6 +45,7 @@ public sealed class GatewayLiveFixture private const string EnvApiKey = "HISTGW_GATEWAY_APIKEY"; private const string EnvTestTag = "HISTGW_TEST_TAG"; private const string EnvWriteSandboxTag = "HISTGW_WRITE_SANDBOX_TAG"; + private const string EnvBoolSandboxTag = "HISTGW_BOOL_SANDBOX_TAG"; private const string EnvAlarmSource = "HISTGW_ALARM_SOURCE"; private const string EnvAllowUntrusted = "HISTGW_GATEWAY_ALLOW_UNTRUSTED"; @@ -68,6 +70,7 @@ public sealed class GatewayLiveFixture _apiKey = Trimmed(EnvApiKey); TestTag = Trimmed(EnvTestTag); WriteSandboxTag = Trimmed(EnvWriteSandboxTag); + BoolSandboxTag = Trimmed(EnvBoolSandboxTag); AlarmSource = Trimmed(EnvAlarmSource); _allowUntrusted = string.Equals( Trimmed(EnvAllowUntrusted), "true", StringComparison.OrdinalIgnoreCase); @@ -105,6 +108,10 @@ public sealed class GatewayLiveFixture /// The Float sandbox tag for the write round-trip (HISTGW_WRITE_SANDBOX_TAG); null when unset. public string? WriteSandboxTag { get; } + /// The dedicated Boolean sandbox tag for the 0.2.0 Boolean EnsureTags leg + retype probe + /// (HISTGW_BOOL_SANDBOX_TAG, archreview 06/U-7); null when unset. + public string? BoolSandboxTag { get; } + /// The source name for the alarm round-trip (HISTGW_ALARM_SOURCE); null when unset. public string? AlarmSource { get; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs index 8479aaa1..96260f1d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs @@ -253,4 +253,124 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC HistorianWriteOutcome.Ack, "the alarm SendEvent must be acked (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id)."); } + + /// + /// archreview 06/U-7 — Boolean provisioning leg on the 0.2.0 contract. Through the real + /// , EnsureTags a + /// request for the dedicated Boolean sandbox tag (mapped to Int1 with explicit proto3 + /// presence), assert it is ensured (not failed), then WriteLiveValues a 1.0 and read + /// it back over a wide window. This retires "the live EnsureTags leg has never run on 0.2.0" for + /// the Boolean path. The ±12h read window + timezone caveat mirror . + /// + [Fact] + [Trait("Category", "LiveIntegration")] + public async Task EnsureTags_boolean_sandbox_provisions_as_Int1() + { + if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!); + if (_fx.BoolSandboxTag is null) + Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) to run the 0.2.0 Boolean EnsureTags leg."); + + var ct = TestContext.Current.CancellationToken; + var tag = _fx.BoolSandboxTag; + + // EnsureTags (Boolean → Int1) through the REAL provisioner seam (the same code path deploys use). + await using var writeClient = _fx.CreateClient(); + var provisioner = new GatewayTagProvisioner(writeClient, NullLogger.Instance); + var provision = await provisioner.EnsureTagsAsync( + new[] { new HistorianTagProvisionRequest(tag, DriverDataType.Boolean, null, "OtOpcUa live validation Boolean sandbox") }, + ct); + + provision.Ensured.ShouldBe(1, + "the Boolean sandbox tag must be ensured (needs the gateway on 0.2.0 contracts + an API key carrying historian:tags:write)."); + provision.Failed.ShouldBe(0); + + // WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll). + const ushort goodQuality = 192; // OPC-DA "Good" floor. + var writeUtc = DateTime.UtcNow; + const double written = 1.0; + var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger.Instance); + var acked = await valueWriter.WriteLiveValuesAsync( + tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct); + acked.ShouldBeTrue("the Boolean live write must be acked (gateway RuntimeDb:Enabled=true + EnsureTags-provisioned tag)."); + + await using var dataSource = _fx.CreateDataSource(); + DataValueSnapshot? hit = null; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + do + { + var read = await dataSource.ReadRawAsync( + tag, writeUtc - TimeSpan.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_000, ct); + hit = read.Samples.FirstOrDefault(s => s.Value is double d && Math.Abs(d - written) < 0.5); + if (hit is not null) break; + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + while (DateTime.UtcNow < deadline); + + hit.ShouldNotBeNull($"the written Boolean sample ({written}) should read back from '{tag}'."); + TestContext.Current.SendDiagnosticMessage( + $"Boolean round-trip: EnsureTags(Int1) + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}."); + } + + /// + /// archreview 06/U-7 retype probe — answers the assessment note's open question: what does the + /// deployed AVEVA Historian do when EnsureTags (an upsert) is asked to change an + /// existing tag's type? Against the dedicated Boolean sandbox tag only (never the shared + /// Float write sandbox), EnsureTags first as Float then as Int1 and assert the second + /// call's per-tag outcome is deterministic and observable — either Success=true + /// (retype / tag-versioning accepted) or Success=false with a non-empty Error (mapped + /// to the provisioner's failed tally). The assertion accepts both because the contract + /// guarantees only the observability, not the policy; the observed behavior is emitted as a + /// diagnostic so the run documents which of accepted-versioned / accepted-retyped / rejected the + /// historian exhibits (fed back into docs/Historian.md after the first live run). + /// + [Fact] + [Trait("Category", "LiveIntegration")] + public async Task EnsureTags_type_change_outcome_is_observable() + { + if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!); + if (_fx.BoolSandboxTag is null) + Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a dedicated Boolean sandbox tag to run the retype probe."); + + var ct = TestContext.Current.CancellationToken; + var tag = _fx.BoolSandboxTag; + + await using var client = _fx.CreateClient(); + + // First establish the tag as Float, then ask EnsureTags to retype it to Int1 (Boolean). + await client.EnsureTagsAsync( + new[] + { + new HistorianTagDefinition + { + TagName = tag, + DataType = HistorianDataType.Float, + EngineeringUnit = string.Empty, + Description = "OtOpcUa live validation retype probe (stage 1: Float)", + }, + }, + ct); + + var retype = await client.EnsureTagsAsync( + new[] + { + new HistorianTagDefinition + { + TagName = tag, + DataType = HistorianDataType.Int1, + EngineeringUnit = string.Empty, + Description = "OtOpcUa live validation retype probe (stage 2: Int1)", + }, + }, + ct); + + var outcome = retype.Results.ShouldHaveSingleItem(); + var observable = outcome.Success || !string.IsNullOrEmpty(outcome.Error); + observable.ShouldBeTrue( + "the retype outcome must be deterministic + observable: either Success=true (retype/version accepted) " + + "or Success=false with a non-empty Error (mapped to the provisioner's failed tally)."); + + TestContext.Current.SendDiagnosticMessage( + $"retype probe '{tag}' Float→Int1: Success={outcome.Success}, Error='{outcome.Error}'. " + + "(records the deployed historian's in-place analog-retype policy — feed this back into docs/Historian.md)."); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs new file mode 100644 index 00000000..d374c6b9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// archreview 06/S-11 — verifies the net-new (built on +/// the shared ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder). +/// The fail tier is deliberately narrow: only provably-crashing configs fail — an enabled +/// historian (or an enabled AlarmHistorian, which sources its gateway connection from the +/// ServerHistorian section) with an empty / non-absolute / non-http(s) Endpoint, i.e. +/// exactly the configs that would otherwise throw deep in the +/// gateway client factory at startup. Empty ApiKey / non-positive MaxTieClusterOverfetch +/// stay operator warnings in (they degrade, not crash). +/// +public sealed class ServerHistorianOptionsValidatorTests +{ + private static ServerHistorianOptionsValidator Sut(bool alarmHistorianEnabled = false) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false", + }) + .Build(); + return new ServerHistorianOptionsValidator(configuration); + } + + /// Enabled historian with an empty endpoint fails (would crash-loop in the factory). + [Fact] + public void Enabled_with_empty_endpoint_fails() + { + var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "" }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); + } + + /// Enabled with a relative (non-absolute) URI fails. + [Fact] + public void Enabled_with_relative_uri_fails() + { + var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "host:5222" }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); + } + + /// Enabled with a non-http(s) scheme (ftp) fails. + [Fact] + public void Enabled_with_bad_scheme_fails() + { + var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "ftp://host:5222" }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); + } + + /// Enabled with a valid absolute https endpoint succeeds. + [Fact] + public void Enabled_with_valid_https_endpoint_succeeds() + { + var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222" }); + + result.Succeeded.ShouldBeTrue(); + } + + /// A disabled section may legitimately be empty — no failure. + [Fact] + public void Disabled_with_empty_endpoint_succeeds() + { + var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" }); + + result.Succeeded.ShouldBeTrue(); + } + + /// + /// The residual corner: ServerHistorian:Enabled=false but AlarmHistorian:Enabled=true + /// (which sources its connection from the ServerHistorian section) with an empty endpoint fails — + /// and the message names BOTH sections so the operator understands why a disabled section is required. + /// + [Fact] + public void Disabled_but_alarm_historian_enabled_with_empty_endpoint_fails_naming_both_sections() + { + var result = Sut(alarmHistorianEnabled: true) + .Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => + f.Contains("ServerHistorian:Endpoint") && f.Contains("AlarmHistorian:Enabled")); + } + + /// Alarm-only mode with a valid endpoint succeeds. + [Fact] + public void Disabled_but_alarm_historian_enabled_with_valid_endpoint_succeeds() + { + var result = Sut(alarmHistorianEnabled: true) + .Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "https://host:5222" }); + + result.Succeeded.ShouldBeTrue(); + } + + /// + /// Wiring guard (the "register-AND-consume" trap): resolving IOptions<ServerHistorianOptions>.Value + /// after throws + /// for an enabled+empty section — proving the validator is + /// attached to the options pipeline (not merely defined). ValidateOnStart reaching host start + /// is already proven by the LDAP precedent. + /// + [Fact] + public void AddValidatedOptions_wiring_throws_on_enabled_empty_endpoint() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ServerHistorian:Enabled"] = "true", + ["ServerHistorian:Endpoint"] = "", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddValidatedOptions( + configuration, ServerHistorianOptions.SectionName); + using var provider = services.BuildServiceProvider(); + + var ex = Should.Throw( + () => _ = provider.GetRequiredService>().Value); + ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs index d4e2eb04..88acedae 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs @@ -21,6 +21,16 @@ public sealed class ServerHistorianOptionsTests Assert.Contains(w, m => m.Contains("Endpoint")); } + [Fact] + public void Enabled_with_malformed_endpoint_warns() + { + // archreview 06/S-11 warning parity: a non-empty but unparseable / non-absolute Endpoint warns + // too, so the Runtime-side registration logs describe malformed endpoints (not just empty ones), + // keeping this surface consistent with the Host's fail-fast ServerHistorianOptionsValidator. + var w = new ServerHistorianOptions { Enabled = true, Endpoint = "not a uri", ApiKey = "histgw_x_y" }.Validate(); + Assert.Contains(w, m => m.Contains("Endpoint")); + } + [Fact] public void Enabled_without_apikey_warns() {