6c4d29902e
v2-ci / unit-tests (push) Has been cancelled
#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via historianTagname re-typed/re-described it on every add and host start. - TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly): HistoryRead from an existing historian tag that OtOpcUa never provisions and never records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid. AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the runtime parse. - ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly (Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start. - Default for an explicit historianTagname stays OWNED: overrides pin owned history across renames, a read-only default would silently stop provisioning + recording. #898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized Float/Double/Byte/SByte tags were never provisioned. New shared HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16 (lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject). UPGRADE: the first host start after this provisions (upserts) every existing historized Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
137 lines
8.3 KiB
C#
137 lines
8.3 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using ZB.MOM.WW.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
|
|
/// <summary>
|
|
/// Fail-fast startup validator for <see cref="ServerHistorianOptions"/> (archreview 06/S-11),
|
|
/// built on the shared <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>.
|
|
/// Wired via <c>AddValidatedOptions</c> so a provably-crashing historian config fails host startup
|
|
/// with a named, aggregated <c>OptionsValidationException</c> instead of the raw
|
|
/// <see cref="System.UriFormatException"/> the gateway client factory's <c>new Uri(...)</c> would
|
|
/// otherwise throw at first resolution (a crash-loop under a service/docker restart policy).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Consumer-gated, not section-gated.</b> The load-bearing question is not "is
|
|
/// <c>ServerHistorian</c> enabled?" but "is its gateway connection <em>consumed</em> by anything
|
|
/// enabled?". The alarm-history sink (<c>AlarmHistorian:Enabled=true</c>) sources its
|
|
/// endpoint/key/TLS from the <c>ServerHistorian</c> section independently of
|
|
/// <see cref="ServerHistorianOptions.Enabled"/>, so it must also gate this check — otherwise the
|
|
/// <c>AlarmHistorian:Enabled=true</c> / <c>ServerHistorian:Enabled=false</c> corner crashes with
|
|
/// zero warning. Continuous historization is already co-gated on
|
|
/// <see cref="ServerHistorianOptions.Enabled"/> in the Host, so <c>Enabled</c> covers it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Fail tier = provably-crashing configs only.</b> Three settings qualify, and they all fail
|
|
/// the same way — <c>HistorianGatewayClientOptions.Validate()</c> throws inside the client
|
|
/// factory before any call is attempted, so the host dies at startup:
|
|
/// an empty / non-absolute / non-http(s) <c>Endpoint</c>; an empty <c>ApiKey</c> ("The gateway
|
|
/// API key must not be empty"); and a <c>UseTls</c> that disagrees with the endpoint scheme
|
|
/// ("UseTls requires an https gateway endpoint" / "An https gateway endpoint requires UseTls").
|
|
/// A non-positive <c>MaxTieClusterOverfetch</c> genuinely degrades rather than crashes (the node
|
|
/// manager surfaces a Bad read), so it stays an operator warning in
|
|
/// <see cref="ServerHistorianOptions.Validate"/>. The <c>Endpoint</c> value is not a secret and
|
|
/// is echoed to make each error actionable; the <c>ApiKey</c> is never surfaced.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The <c>ApiKey</c> and <c>UseTls</c> checks were added after two consecutive live-rig
|
|
/// crash-loops</b> (LocalDb Phase 2 gate). Both had been classified as degrading, on the
|
|
/// assumption that a bad client configuration would connect and be rejected by the gateway — but
|
|
/// the client validates its own options at construction, so the process dies during Akka startup
|
|
/// instead, which is precisely the failure this validator exists to convert into a named,
|
|
/// aggregated error. The lesson generalises: "degrades" is only true of settings the client
|
|
/// does not itself validate.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<ServerHistorianOptions>
|
|
{
|
|
private readonly IConfiguration _configuration;
|
|
|
|
/// <summary>Creates the validator over the app configuration (read to resolve the consumer gate).</summary>
|
|
/// <param name="configuration">The app configuration; used to read <c>AlarmHistorian:Enabled</c>.</param>
|
|
public ServerHistorianOptionsValidator(IConfiguration configuration) =>
|
|
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
|
|
|
/// <inheritdoc />
|
|
protected override void Validate(ValidationBuilder builder, ServerHistorianOptions options)
|
|
{
|
|
// Unknown backend always fails, with no I/O and regardless of the consumer gate.
|
|
builder.RequireThat(
|
|
HistorianBackendRegistry.IsKnown(options.Backend),
|
|
$"ServerHistorian:Backend '{options.Backend}' is not a known historian backend. "
|
|
+ $"Allowed values: {string.Join(", ", HistorianBackendRegistry.KnownBackends)}.");
|
|
|
|
builder.RequireThat(
|
|
ServerHistorianOptions.TryParseProvisionPolicy(options.ProvisionTags, out var provisionPolicy),
|
|
$"ServerHistorian:ProvisionTags '{options.ProvisionTags}' is not a known provisioning policy. "
|
|
+ $"Allowed values: {string.Join(", ", Enum.GetNames<HistorianProvisionPolicy>())}.");
|
|
|
|
// CreateOnly needs an existence lookup only the Gateway provisioner makes; the SQL backends' stores are
|
|
// OtOpcUa's own tables (lmxopcua#899).
|
|
builder.RequireThat(
|
|
!options.Enabled || provisionPolicy != HistorianProvisionPolicy.CreateOnly || options.IsGatewayBackend,
|
|
$"ServerHistorian:ProvisionTags=CreateOnly is supported only by the Gateway backend (Backend is '{options.Backend}'). "
|
|
+ "Use CreateOrUpdate or Off, or opt individual tags out with historianReadOnly.");
|
|
|
|
var alarmHistorianEnabled = _configuration.GetValue<bool>("AlarmHistorian:Enabled");
|
|
var consumed = options.Enabled || alarmHistorianEnabled;
|
|
|
|
// A section nobody consumes may legitimately be empty (the docker-dev default) — no checks.
|
|
if (!consumed) return;
|
|
|
|
// Gateway connection checks apply only to the Gateway backend; other backends validate their own
|
|
// options (added with the backend) here, under the same consumer gate.
|
|
if (string.Equals(options.Backend?.Trim(), HistorianBackendRegistry.SqlServerBackendName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
foreach (var error in HistorianBackendRegistry.BindSqlServer(_configuration).Validate())
|
|
builder.RequireThat(false, error);
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(options.Backend?.Trim(), HistorianBackendRegistry.QuestDbBackendName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
foreach (var error in HistorianBackendRegistry.BindQuestDb(_configuration).Validate())
|
|
builder.RequireThat(false, error);
|
|
return;
|
|
}
|
|
|
|
if (!options.IsGatewayBackend) 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}.");
|
|
|
|
// Deliberately not echoed: unlike the endpoint, the key is a secret.
|
|
builder.RequireThat(
|
|
!string.IsNullOrWhiteSpace(options.ApiKey),
|
|
$"ServerHistorian:ApiKey is empty — {reason}. The gateway client rejects a keyless "
|
|
+ "configuration at construction, so the host would crash on startup rather than degrade. "
|
|
+ "Supply it via the environment variable ServerHistorian__ApiKey.");
|
|
|
|
// The flag and the scheme must agree in BOTH directions — the client throws either way
|
|
// ("UseTls requires an https gateway endpoint" / "An https gateway endpoint requires UseTls").
|
|
// Only meaningful once the endpoint parsed; a malformed one already failed above.
|
|
if (endpointValid)
|
|
{
|
|
var https = uri!.Scheme == Uri.UriSchemeHttps;
|
|
builder.RequireThat(
|
|
options.UseTls == https,
|
|
$"ServerHistorian:UseTls is {options.UseTls.ToString().ToLowerInvariant()} but the "
|
|
+ $"endpoint is '{options.Endpoint}' — {reason}. The flag must match the scheme "
|
|
+ "(https ⇒ UseTls=true, http ⇒ UseTls=false); the gateway client rejects a mismatch "
|
|
+ "at construction, crashing the host on startup.");
|
|
}
|
|
}
|
|
}
|