diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 15dc6e54..a89f3221 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -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); diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs index 1985ff29..35559383 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs @@ -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(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(); } + /// Non-fatal configuration findings. Collected separately from because this runs before the logger exists; Program.cs logs each entry at Warning once Serilog is configured. + public static IReadOnlyList CollectWarnings(IConfiguration configuration) + { + var role = configuration.GetSection("ScadaBridge:Node")["Role"]; + var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get>(); + 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(); + } + /// /// Extracts the TCP port from an Akka seed-node address of the form /// akka.tcp://system@host:port. Returns -1 when no port can be parsed. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs index c489e9e9..a89216d3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs @@ -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(() => 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() {