From 5369994e578cdea0fcd5b43af86401fd64523e1c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:54:37 -0400 Subject: [PATCH] =?UTF-8?q?fix(r2-06):=20GREEN=20=E2=80=94=20ServerHistori?= =?UTF-8?q?anOptionsValidator=20(consumer-gated=20fail-fast=20on=20bad=20E?= =?UTF-8?q?ndpoint)=20(06/S-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erverhistorian-failfast-plan.md.tasks.json | 2 +- .../ServerHistorianOptionsValidator.cs | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs 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 0b287f11..49394bf0 100644 --- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json +++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json @@ -27,7 +27,7 @@ { "id": "T4", "subject": "GREEN: implement ServerHistorianOptionsValidator (OptionsValidatorBase, ctor IConfiguration, consumed = Enabled || AlarmHistorian:Enabled, fail on empty/non-absolute/non-http(s) Endpoint)", - "status": "pending", + "status": "completed", "blockedBy": [ "T3" ] 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}."); + } +}