feat(host): StartupValidator warns when a clustered central keeps per-node inbound API keys; rejects an unknown ApiKeyStore:Provider at boot; Program.cs logs CollectWarnings once Serilog is up
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -162,6 +162,12 @@ try
|
||||
{
|
||||
Log.Information("Starting ScadaBridge host as {Role} on {Hostname}", nodeRole, nodeHostname);
|
||||
|
||||
// Non-fatal preflight findings (StartupValidator.CollectWarnings) are logged here rather than
|
||||
// inside Validate because the validator runs before Serilog exists. Today: a clustered Central
|
||||
// still on the per-node SQLite inbound API key store (design 2026-09-02, register row 108).
|
||||
foreach (var warning in StartupValidator.CollectWarnings(configuration))
|
||||
Log.Warning("Startup configuration warning: {Warning}", warning);
|
||||
|
||||
if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -88,7 +88,16 @@ public static class StartupValidator
|
||||
// baseline used elsewhere.
|
||||
.Require("ScadaBridge:InboundApi:ApiKeyPepper",
|
||||
value => !string.IsNullOrEmpty(value) && value.Length >= 16,
|
||||
"is required and must be at least 16 characters for Central (backs the inbound API-key peppered-HMAC verifier)"))
|
||||
"is required and must be at least 16 characters for Central (backs the inbound API-key peppered-HMAC verifier)")
|
||||
// The inbound API key store provider (design 2026-09-02). Not required — an
|
||||
// unset value defaults to Sqlite (InboundApiKeyStoreOptions.ReadProvider) — but
|
||||
// when it IS set, it must be one of the two legal values so a typo fails fast at
|
||||
// boot instead of throwing later out of ReadProvider once the host is already up.
|
||||
.Require("ScadaBridge:InboundApi:ApiKeyStore:Provider",
|
||||
value => string.IsNullOrWhiteSpace(value)
|
||||
|| (Enum.TryParse<InboundApiKeyStoreProvider>(value, ignoreCase: true, out var provider)
|
||||
&& Enum.IsDefined(provider)),
|
||||
"must be 'Sqlite' or 'SqlServer' when set"))
|
||||
// SeedNodes count (unconditional, after SiteId).
|
||||
.Require("ScadaBridge:Cluster:SeedNodes",
|
||||
_ => seedNodes != null && seedNodes.Count >= 2,
|
||||
@@ -211,6 +220,28 @@ public static class StartupValidator
|
||||
.ThrowIfInvalid();
|
||||
}
|
||||
|
||||
/// <summary>Non-fatal configuration findings. Collected separately from <see cref="Validate"/> because this runs before the logger exists; Program.cs logs each entry at Warning once Serilog is configured.</summary>
|
||||
public static IReadOnlyList<string> CollectWarnings(IConfiguration configuration)
|
||||
{
|
||||
var role = configuration.GetSection("ScadaBridge:Node")["Role"];
|
||||
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
|
||||
var provider = InboundApiKeyStoreOptions.ReadProvider(configuration);
|
||||
|
||||
if (role == "Central"
|
||||
&& seedNodes is { Count: >= 2 }
|
||||
&& provider == InboundApiKeyStoreProvider.Sqlite)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
$"Inbound API keys use the per-node SQLite store on a {seedNodes.Count}-node central "
|
||||
+ "cluster; a failover will reject every inbound client until keys are re-issued. Set "
|
||||
+ "ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02)."
|
||||
};
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the TCP port from an Akka seed-node address of the form
|
||||
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
|
||||
|
||||
@@ -694,6 +694,91 @@ public class StartupValidatorTests
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectWarnings_WarnsForTwoSeedCentralOnSqlite()
|
||||
{
|
||||
// Design 2026-09-02: the inbound API key store is per-node SQLite by default, so a
|
||||
// 2+-node central cluster is one failover away from rejecting every inbound client.
|
||||
var config = BuildConfig(ValidCentralConfig());
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
var warning = Assert.Single(warnings);
|
||||
Assert.Contains("2-node", warning);
|
||||
Assert.Contains("SQLite", warning);
|
||||
Assert.Contains("Provider=SqlServer", warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectWarnings_WarnsWhenProviderExplicitlySqlite()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "sqlite";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
Assert.Single(warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectWarnings_SilentForSqlServer()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
Assert.Empty(warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectWarnings_SilentForSingleSeedCentral()
|
||||
{
|
||||
// CollectWarnings is independent of Validate's own >=2-seeds requirement — it should
|
||||
// simply not fire the failover warning when there is nothing to fail over to.
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Cluster:SeedNodes:1");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
Assert.Empty(warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectWarnings_SilentForSite()
|
||||
{
|
||||
var config = BuildConfig(ValidSiteConfig());
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
Assert.Empty(warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsUnknownApiKeyStoreProvider()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "Postgres";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("ScadaBridge:InboundApi:ApiKeyStore:Provider", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsProviderCaseInsensitively()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SQLSERVER";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleErrors_AllReported()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user