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:
Joseph Doherty
2026-09-02 23:26:34 -04:00
parent 06be0b9e64
commit d6fd8c400c
3 changed files with 123 additions and 1 deletions
@@ -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.