f9f1b8fcee
Gate record: docs/plans/2026-07-20-localdb-phase2-live-gate.md. Checks 1, 2, 5, 6 pass. Checks 3 and 4 are NOT satisfied — the defect they were meant to confirm turned out to be the opposite of what the plan assumed. Three defects crash-looped every driver node before check 1 could even run: 1. An empty ServerHistorian:ApiKey kills the host. ServerHistorianOptions- Validator exists to turn exactly that class of failure into a named OptionsValidationException, but its documented fail tier explicitly excluded ApiKey on the reasoning that a keyless client "degrades — the gateway rejects calls". It does not: the client validates its own options at construction, so the process dies during Akka startup and never makes a call. 2. UseTls disagreeing with the endpoint scheme kills the host too, in both directions (both messages confirmed in the shipped client assembly). Moving an endpoint from https to http without clearing UseTls is an ordinary migration slip. 3. Plaintext h2c was UNREACHABLE. HistorianGatewayClientAdapter forwarded the TLS-only options unconditionally, and AllowUntrustedServerCertificate defaults to false, so it always sent RequireCertificateValidation=true — which the client rejects outright when UseTls=false. Every http:// deployment crashed, though the scheme is documented as the supported way to select h2c, and the only workaround was to assert a certificate posture for a connection that has no certificate. The fourth was the blocker, and it is Phase 2's own: 4. The drain gate deferred to a Primary that cannot deliver. Redundancy roles are elected CLUSTER-WIDE; the alarm queue is PAIR-LOCAL. On the rig the elected driver Primary is central-1 — it carries the driver Akka role, replicates nobody's LocalDb and does not even run the alarm historian — so every driver node logged "Historian drain suspended", including the two site-b nodes that have no peer at all. Nothing drained anywhere, where before Phase 2 it drained fine. The cost is not a duplicate; it is the buffer growing to the capacity wall and evicting the audit trail it exists to protect. Fixed in three layers: a separate ShouldDrainAlarmHistory policy (unknown role drains; the two gates now deliberately disagree, and a test pins that); peer- host matching in DriverHostActor so a node stands down only for a Primary holding its rows; and AddAlarmHistorian short-circuiting the gate when replication is unconfigured — testing BOTH Replication:PeerAddress and SyncListenPort, since only the dialing half sets the former while both halves share the queue. Every one of these follows from the asymmetry: a false allow costs a duplicate row, which at-least-once delivery already accepts and payload-hash ids collapse; a false deny loses data silently. A third vacuous test, caught by the same delete-the-guard discipline: the role-view tests stayed green with the guard removed, because AwaitAssert polls until an assertion passes and the assertion was "reads open" — which is the SEEDED value, satisfied at the first poll before the actor processed anything. They now assert the sequence of published values through a recording view; the control then goes red for exactly the cases that matter. Migration evidence: 11 legacy rows across two deliberately overlapping files converged to exactly 9 identical rows on both nodes, proving D-6's payload-hash identity on real nodes rather than in a fixture. Open design fork, recorded in the gate doc rather than decided here: a pair cannot currently identify its own Primary, so both halves drain. Safe in every topology — nothing loses data — but the gate's de-duplication benefit is unrealised until roles are scoped per pair. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
274 lines
12 KiB
C#
274 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// archreview 06/S-11 — verifies the net-new <see cref="ServerHistorianOptionsValidator"/> (built on
|
|
/// the shared <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>).
|
|
/// The fail tier is deliberately narrow: only <b>provably-crashing</b> configs fail — an enabled
|
|
/// historian (or an enabled <c>AlarmHistorian</c>, which sources its gateway connection from the
|
|
/// <c>ServerHistorian</c> section) with an empty / non-absolute / non-http(s) <c>Endpoint</c>, i.e.
|
|
/// exactly the configs that would otherwise throw <see cref="System.UriFormatException"/> deep in the
|
|
/// gateway client factory at startup. Empty <c>ApiKey</c> / non-positive <c>MaxTieClusterOverfetch</c>
|
|
/// stay operator warnings in <see cref="ServerHistorianOptions.Validate"/> (they degrade, not crash).
|
|
/// </summary>
|
|
public sealed class ServerHistorianOptionsValidatorTests
|
|
{
|
|
private static ServerHistorianOptionsValidator Sut(bool alarmHistorianEnabled = false)
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false",
|
|
})
|
|
.Build();
|
|
return new ServerHistorianOptionsValidator(configuration);
|
|
}
|
|
|
|
/// <summary>Enabled historian with an empty endpoint fails (would crash-loop in the factory).</summary>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Enabled with a relative (non-absolute) URI fails.</summary>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Enabled with a non-http(s) scheme (ftp) fails.</summary>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enabled with a valid absolute https endpoint <b>and a key</b> succeeds. The key is not
|
|
/// incidental: this case previously omitted it and still asserted success, which pinned a config
|
|
/// that actually crash-loops the host — see <see cref="Consumed_with_empty_api_key_fails"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Enabled_with_valid_https_endpoint_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret",
|
|
});
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>A disabled section may legitimately be empty — no failure.</summary>
|
|
[Fact]
|
|
public void Disabled_with_empty_endpoint_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" });
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The residual corner: <c>ServerHistorian:Enabled=false</c> but <c>AlarmHistorian:Enabled=true</c>
|
|
/// (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.
|
|
/// </summary>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Alarm-only mode with a valid endpoint and a key succeeds.</summary>
|
|
[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", ApiKey = "histgw_id_secret",
|
|
});
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// An empty <c>ApiKey</c> on a consumed section fails, because it <b>crashes</b> — it does not
|
|
/// degrade.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Found by the LocalDb Phase 2 live gate. The rig booted with
|
|
/// <c>AlarmHistorian:Enabled=true</c> and a valid endpoint but no key, and every driver
|
|
/// node crash-looped on
|
|
/// <c>ArgumentException: The gateway API key must not be empty (Parameter 'ApiKey')</c>
|
|
/// thrown by <c>HistorianGatewayClientOptions.Validate()</c> — reached through
|
|
/// <c>GatewayHistorian.CreateAlarmWriter</c> during Akka startup.
|
|
/// </para>
|
|
/// <para>
|
|
/// That is the *same* factory path, and the same crash-loop-under-a-restart-policy
|
|
/// failure mode, this validator was built to convert into a named
|
|
/// <c>OptionsValidationException</c>. The previous "empty ApiKey degrades, the gateway
|
|
/// just rejects calls" premise was simply wrong: the client validates its own options
|
|
/// at construction, so the process never reaches the point of making a call.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public void Consumed_with_empty_api_key_fails()
|
|
{
|
|
var result = Sut(alarmHistorianEnabled: true)
|
|
.Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = false, Endpoint = "https://host:5222", ApiKey = "",
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f =>
|
|
f.Contains("ServerHistorian:ApiKey") && f.Contains("AlarmHistorian:Enabled"));
|
|
}
|
|
|
|
/// <summary>The read path has the identical crash, so it is gated identically.</summary>
|
|
[Fact]
|
|
public void Enabled_with_empty_api_key_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = "",
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The key must never be echoed. An endpoint is not a secret and is quoted to make the error
|
|
/// actionable; a key is, so the failure names only the setting.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Api_key_failure_never_echoes_the_key()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = " ",
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldNotContain(f => f.Contains(" ", StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>A section nobody consumes may be keyless — no failure.</summary>
|
|
[Fact]
|
|
public void Disabled_with_empty_api_key_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, ApiKey = "" });
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>UseTls=true</c> against an <c>http://</c> endpoint fails — the third member of the
|
|
/// crash-at-construction family, and the easiest of the three for an operator to trip.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Also found by the Phase 2 live gate, immediately after the <c>ApiKey</c> fix: the rig points
|
|
/// at a deliberately unresolvable <c>http://</c> endpoint, and <c>UseTls</c> defaults to
|
|
/// <see langword="true"/>, so every node crash-looped a second time on
|
|
/// <c>ArgumentException: UseTls requires an https gateway endpoint</c>. Switching an endpoint
|
|
/// from https to http without clearing <c>UseTls</c> is an ordinary migration slip, and it takes
|
|
/// the host down rather than degrading it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void Consumed_with_use_tls_against_http_endpoint_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = true,
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f =>
|
|
f.Contains("ServerHistorian:UseTls") && f.Contains("http://host:5222"));
|
|
}
|
|
|
|
/// <summary>An http endpoint with TLS explicitly off is the valid h2c shape — no failure.</summary>
|
|
[Fact]
|
|
public void Consumed_with_http_endpoint_and_tls_off_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
|
});
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The mirror slip — <c>UseTls=false</c> against an <c>https://</c> endpoint — also fails, so the
|
|
/// scheme and the flag are pinned to agree in both directions rather than in one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Consumed_with_tls_off_against_https_endpoint_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:UseTls"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wiring guard (the "register-AND-consume" trap): resolving <c>IOptions<ServerHistorianOptions>.Value</c>
|
|
/// after <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions,TValidator}"/> throws
|
|
/// <see cref="OptionsValidationException"/> for an enabled+empty section — proving the validator is
|
|
/// attached to the options pipeline (not merely defined). <c>ValidateOnStart</c> reaching host start
|
|
/// is already proven by the LDAP precedent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AddValidatedOptions_wiring_throws_on_enabled_empty_endpoint()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ServerHistorian:Enabled"] = "true",
|
|
["ServerHistorian:Endpoint"] = "",
|
|
})
|
|
.Build();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(configuration);
|
|
services.AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>(
|
|
configuration, ServerHistorianOptions.SectionName);
|
|
using var provider = services.BuildServiceProvider();
|
|
|
|
var ex = Should.Throw<OptionsValidationException>(
|
|
() => _ = provider.GetRequiredService<IOptions<ServerHistorianOptions>>().Value);
|
|
ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
|
}
|
|
}
|