diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs
index 9a766a4d..8eb7deff 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs
@@ -60,18 +60,19 @@ var configuration = new ConfigurationBuilder()
// scadaproj#4) — an expander left on SQLite there would resolve pre-host ${secret:} references
// from a stale/empty local store, silently diverging from what the node's own hub serves. Every
// other case (sites, SqlServer mode, replication off) keeps the local SQLite path exactly as it
-// always was. Central+Grpc with a BLANK connection string deliberately falls through to the
-// SQLite path too: that boot is about to fail in AddScadaBridgeSecrets with the message naming
-// Secrets:SqlServer:ConnectionString, so the throw is not duplicated here.
+// always was. A blank or ${secret:}-valued connection string fails HERE, through the same
+// EnsureCentralSharedStoreConnectionString check AddScadaBridgeSecrets runs — this is the
+// earliest code that would otherwise hand the bad value to SqlConnection, whose generic
+// "initialization string" format error would bury the designed message.
var expanderUsesSharedSqlStore =
string.Equals(
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
- && SecretsRegistration.UsesGrpcHub(configuration)
- && !string.IsNullOrWhiteSpace(configuration[SecretsRegistration.HubConnectionStringKey]);
+ && SecretsRegistration.UsesGrpcHub(configuration);
var expanderServices = new ServiceCollection();
if (expanderUsesSharedSqlStore)
{
+ SecretsRegistration.EnsureCentralSharedStoreConnectionString(configuration);
expanderServices.AddZbSecretsSqlServerStore(configuration, "Secrets");
}
else
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs
index ddcbe189..9ecb761a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs
@@ -185,6 +185,59 @@ public static class SecretsRegistration
&& ResolveReplicationMode(config) == SecretsReplicationMode.Grpc;
}
+ ///
+ /// The one definition of "a Central node in Grpc mode has a usable shared-store connection
+ /// string", shared by and the Layer-A expander in
+ /// Program.cs so the two can never diverge. The expander runs FIRST on a real boot —
+ /// without this sharing, a bad value would die inside SqlConnection with a generic
+ /// "initialization string" format error before the designed message ever fired.
+ ///
+ /// Application configuration.
+ /// The validated connection string.
+ ///
+ /// is blank or contains a ${secret:} reference.
+ ///
+ internal static string EnsureCentralSharedStoreConnectionString(IConfiguration config)
+ {
+ var hubConnectionString = config[HubConnectionStringKey];
+
+ if (string.IsNullOrWhiteSpace(hubConnectionString))
+ {
+ // Fail closed, matching the mode's philosophy: central in Grpc mode REQUIRES the
+ // shared SQL store. An independent local store per central node is exactly the
+ // divergence scadaproj#4 recorded — one central hub answered authenticated followers
+ // with an EMPTY manifest, so a hub failover would "succeed" against nothing and stop
+ // convergence silently.
+ throw new InvalidOperationException(
+ $"{HubConnectionStringKey} is empty, but this node is Central with "
+ + $"{ReplicationModeKey}=Grpc. Central in Grpc mode requires the "
+ + "SHARED SQL-Server secret store — an independent local store per "
+ + "central node is the divergence scadaproj#4 recorded (a hub "
+ + "failover would 'succeed' against an empty manifest). Supply the "
+ + "connection string via appsettings or the environment "
+ + "(Secrets__SqlServer__ConnectionString).");
+ }
+
+ if (hubConnectionString.Contains("${secret:", StringComparison.Ordinal))
+ {
+ // The secrets store's own connection string can never be a secret reference: the
+ // pre-host expander needs the store this string points at in order to resolve it —
+ // bootstrap circularity, the same rule the class remarks document for the hub bearer
+ // token. The message deliberately does not echo the configured value: a mistyped
+ // value here could be a pasted real credential.
+ throw new InvalidOperationException(
+ $"{HubConnectionStringKey} contains a ${{secret:}} reference. The "
+ + "secret store's own connection string can never be a secret "
+ + "reference — the pre-host expander needs the store this string "
+ + "points at in order to resolve it (bootstrap circularity, the "
+ + "same rule as Secrets:GrpcHub:BearerToken). Supply a literal "
+ + "value via appsettings or the environment "
+ + "(Secrets__SqlServer__ConnectionString).");
+ }
+
+ return hubConnectionString;
+ }
+
///
/// Registers the host container's secret store: a plain local SQLite store by default, or a
/// replicating topology when is set — a shared SQL-Server
@@ -212,45 +265,11 @@ public static class SecretsRegistration
switch (role)
{
case SecretsNodeRole.Central:
- // Both pre-checks run BEFORE any registration so the boot failure names the
- // real problem instead of surfacing later as a package validation message or a
- // malformed-connection-string fault on first use.
- var hubConnectionString = config[HubConnectionStringKey];
-
- if (string.IsNullOrWhiteSpace(hubConnectionString))
- {
- // Fail closed, matching the mode's philosophy: central in Grpc mode
- // REQUIRES the shared SQL store. An independent local store per central
- // node is exactly the divergence scadaproj#4 recorded — one central hub
- // answered authenticated followers with an EMPTY manifest, so a hub
- // failover would "succeed" against nothing and stop convergence silently.
- throw new InvalidOperationException(
- $"{HubConnectionStringKey} is empty, but this node is Central with "
- + $"{ReplicationModeKey}=Grpc. Central in Grpc mode requires the "
- + "SHARED SQL-Server secret store — an independent local store per "
- + "central node is the divergence scadaproj#4 recorded (a hub "
- + "failover would 'succeed' against an empty manifest). Supply the "
- + "connection string via appsettings or the environment "
- + "(Secrets__SqlServer__ConnectionString).");
- }
-
- if (hubConnectionString.Contains("${secret:", StringComparison.Ordinal))
- {
- // The secrets store's own connection string can never be a secret
- // reference: the pre-host expander needs the store this string points at
- // in order to resolve it — bootstrap circularity, the same rule the class
- // remarks document for the hub bearer token. The message deliberately
- // does not echo the configured value: a mistyped value here could be a
- // pasted real credential.
- throw new InvalidOperationException(
- $"{HubConnectionStringKey} contains a ${{secret:}} reference. The "
- + "secret store's own connection string can never be a secret "
- + "reference — the pre-host expander needs the store this string "
- + "points at in order to resolve it (bootstrap circularity, the "
- + "same rule as Secrets:GrpcHub:BearerToken). Supply a literal "
- + "value via appsettings or the environment "
- + "(Secrets__SqlServer__ConnectionString).");
- }
+ // Runs BEFORE any registration so the boot failure names the real problem
+ // instead of surfacing later as a package validation message or a
+ // malformed-connection-string fault on first use. On a real boot the Layer-A
+ // expander in Program.cs has already run the same check even earlier.
+ EnsureCentralSharedStoreConnectionString(config);
// Central's store is the SHARED SQL-Server store: registers
// SqlServerSecretStore as ISecretStore BEFORE calling AddZbSecrets internally,