refactor: ScadaBridge StartupValidator -> ConfigPreflight (byte-compatible)

This commit is contained in:
Joseph Doherty
2026-06-01 19:04:13 -04:00
parent aac59c9fae
commit 6dbbc7ad04
2 changed files with 84 additions and 75 deletions
@@ -1,3 +1,5 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host; namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary> /// <summary>
@@ -10,92 +12,98 @@ public static class StartupValidator
/// <param name="configuration">The application configuration to validate.</param> /// <param name="configuration">The application configuration to validate.</param>
public static void Validate(IConfiguration configuration) public static void Validate(IConfiguration configuration)
{ {
var errors = new List<string>(); // Resolve the same locals the original imperative validator used, so the
// cross-field predicates below can close over them. ConfigPreflight.Require
// passes config[key] to each predicate, but the cross-field rules ignore that
// argument and read these resolved values instead — preserving the exact
// conditions (and therefore the byte-identical failure messages and ordering)
// of the original StartupValidator.
var nodeSection = configuration.GetSection("ScadaBridge:Node"); var nodeSection = configuration.GetSection("ScadaBridge:Node");
var role = nodeSection["Role"]; var role = nodeSection["Role"];
if (string.IsNullOrEmpty(role) || (role != "Central" && role != "Site"))
errors.Add("ScadaBridge:Node:Role must be 'Central' or 'Site'");
if (string.IsNullOrEmpty(nodeSection["NodeHostname"]))
errors.Add("ScadaBridge:Node:NodeHostname is required");
var portStr = nodeSection["RemotingPort"]; var portStr = nodeSection["RemotingPort"];
if (!int.TryParse(portStr, out var port) || port < 1 || port > 65535) bool portValid = int.TryParse(portStr, out var port) && port >= 1 && port <= 65535;
errors.Add("ScadaBridge:Node:RemotingPort must be 1-65535");
if (role == "Site" && string.IsNullOrEmpty(nodeSection["SiteId"]))
errors.Add("ScadaBridge:Node:SiteId is required for Site nodes");
if (role == "Central")
{
var dbSection = configuration.GetSection("ScadaBridge:Database");
if (string.IsNullOrEmpty(dbSection["ConfigurationDb"]))
errors.Add("ScadaBridge:Database:ConfigurationDb connection string required for Central");
var secSection = configuration.GetSection("ScadaBridge:Security");
if (string.IsNullOrEmpty(secSection["LdapServer"]))
errors.Add("ScadaBridge:Security:LdapServer required for Central");
if (string.IsNullOrEmpty(secSection["JwtSigningKey"]))
errors.Add("ScadaBridge:Security:JwtSigningKey required for Central");
}
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>(); var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
if (seedNodes == null || seedNodes.Count < 2)
errors.Add("ScadaBridge:Cluster:SeedNodes must have at least 2 entries");
if (role == "Site") // GrpcPort: default 8083 when absent; only fails the range rule when the key is
{ // present AND invalid. The out-param assignment mirrors the original so the
var grpcPortStr = nodeSection["GrpcPort"]; // resolved grpcPort feeds the cross-field rules even on a parse failure.
int grpcPort = 8083; // NodeOptions default when the key is absent var grpcPortStr = nodeSection["GrpcPort"];
if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535)) int grpcPort = 8083; // NodeOptions default when the key is absent
errors.Add("ScadaBridge:Node:GrpcPort must be 1-65535"); bool grpcValid = !(grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535));
// Host-007 / REQ-HOST-4: the gRPC (Kestrel HTTP/2) port and the Akka // MetricsPort: default 8084 when absent; same parse-or-default contract as GrpcPort.
// remoting port must differ. Identical values make Kestrel and var metricsPortStr = nodeSection["MetricsPort"];
// Akka.Remote contend for the same TCP port and fail opaquely at int metricsPort = 8084; // NodeOptions default when the key is absent
// runtime. Uses the resolved GrpcPort, including the 8083 default. bool metricsValid = !(metricsPortStr != null && (!int.TryParse(metricsPortStr, out metricsPort) || metricsPort < 1 || metricsPort > 65535));
if (port == grpcPort)
errors.Add("ScadaBridge:Node:GrpcPort must differ from RemotingPort");
var metricsPortStr = nodeSection["MetricsPort"]; ConfigPreflight.For(configuration)
int metricsPort = 8084; // NodeOptions default when the key is absent // Role / NodeHostname / RemotingPort (unconditional)
if (metricsPortStr != null && (!int.TryParse(metricsPortStr, out metricsPort) || metricsPort < 1 || metricsPort > 65535)) .Require("ScadaBridge:Node:Role",
errors.Add("ScadaBridge:Node:MetricsPort must be 1-65535"); _ => !(string.IsNullOrEmpty(role) || (role != "Central" && role != "Site")),
"must be 'Central' or 'Site'")
// Host-007 / REQ-HOST-4: the Kestrel metrics (HTTP/1.1) listener port .Require("ScadaBridge:Node:NodeHostname",
// must differ from BOTH the Akka remoting port and the gRPC port. _ => !string.IsNullOrEmpty(nodeSection["NodeHostname"]),
// A collision makes the metrics listener contend with Akka.Remote or "is required")
// the gRPC listener for the same TCP port and fail opaquely at runtime. .Require("ScadaBridge:Node:RemotingPort",
// Uses the resolved MetricsPort, including the 8084 default. _ => portValid,
if (metricsPort == port) "must be 1-65535")
errors.Add("ScadaBridge:Node:MetricsPort must differ from RemotingPort"); // SiteId (Site only) — note: OUTSIDE the big Site block in the original,
if (metricsPort == grpcPort) // so it must run before the unconditional SeedNodes-count rule.
errors.Add("ScadaBridge:Node:MetricsPort must differ from GrpcPort"); .When(role == "Site", p => p
.Require("ScadaBridge:Node:SiteId",
var dbSection = configuration.GetSection("ScadaBridge:Database"); _ => !string.IsNullOrEmpty(nodeSection["SiteId"]),
if (string.IsNullOrEmpty(dbSection["SiteDbPath"])) "is required for Site nodes"))
errors.Add("ScadaBridge:Database:SiteDbPath required for Site nodes"); // Central-only database/security rules.
.When(role == "Central", p => p
// Host-004: a seed node must reference an Akka.Remote endpoint, never the .Require("ScadaBridge:Database:ConfigurationDb",
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's _ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["ConfigurationDb"]),
// GrpcPort would make a joining node attempt an Akka.Remote TCP "connection string required for Central")
// association against the gRPC listener and fail. .Require("ScadaBridge:Security:LdapServer",
if (seedNodes != null) _ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["LdapServer"]),
"required for Central")
.Require("ScadaBridge:Security:JwtSigningKey",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
"required for Central"))
// SeedNodes count (unconditional, after SiteId).
.Require("ScadaBridge:Cluster:SeedNodes",
_ => seedNodes != null && seedNodes.Count >= 2,
"must have at least 2 entries")
// The big Site-only block: GrpcPort/MetricsPort validity + cross-field
// collisions + SiteDbPath + seed-node-port loop, in the original order.
.When(role == "Site", p =>
{ {
foreach (var seed in seedNodes) // Host-007 / REQ-HOST-4: GrpcPort range, then GrpcPort vs RemotingPort.
{ p.Require("ScadaBridge:Node:GrpcPort", _ => grpcValid, "must be 1-65535");
if (SeedNodePort(seed) == grpcPort) // Identical GrpcPort/RemotingPort make Kestrel and Akka.Remote contend
errors.Add( // for the same TCP port. Uses the resolved GrpcPort, including 8083.
$"ScadaBridge:Cluster:SeedNodes entry '{seed}' must not target the gRPC port " + p.Require("ScadaBridge:Node:GrpcPort", _ => port != grpcPort, "must differ from RemotingPort");
$"({grpcPort}); seed nodes must reference Akka remoting ports");
}
}
}
if (errors.Count > 0) // Host-007 / REQ-HOST-4: MetricsPort range, then MetricsPort vs both ports.
throw new InvalidOperationException( p.Require("ScadaBridge:Node:MetricsPort", _ => metricsValid, "must be 1-65535");
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}"); // The Kestrel metrics (HTTP/1.1) listener port must differ from BOTH the
// Akka remoting port and the gRPC port. Uses the resolved MetricsPort (8084 default).
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != port, "must differ from RemotingPort");
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != grpcPort, "must differ from GrpcPort");
p.Require("ScadaBridge:Database:SiteDbPath",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["SiteDbPath"]),
"required for Site nodes");
// Host-004: a seed node must reference an Akka.Remote endpoint, never the
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's
// GrpcPort would make a joining node attempt an Akka.Remote TCP
// association against the gRPC listener and fail.
foreach (var seed in seedNodes ?? Enumerable.Empty<string>())
{
p.Require("ScadaBridge:Cluster:SeedNodes",
_ => SeedNodePort(seed) != grpcPort,
$"entry '{seed}' must not target the gRPC port " +
$"({grpcPort}); seed nodes must reference Akka remoting ports");
}
})
.ThrowIfInvalid();
} }
/// <summary> /// <summary>
@@ -28,6 +28,7 @@
<!-- Transitive override: Akka.Hosting 1.5.62 pins OpenTelemetry.Api 1.9.0 which is flagged <!-- Transitive override: Akka.Hosting 1.5.62 pins OpenTelemetry.Api 1.9.0 which is flagged
(GHSA-g94r-2vxg-569j, GHSA-8785-wc3w-h8q6). Bumping directly clears both advisories. --> (GHSA-g94r-2vxg-569j, GHSA-8785-wc3w-h8q6). Bumping directly clears both advisories. -->
<PackageReference Include="OpenTelemetry.Api" /> <PackageReference Include="OpenTelemetry.Api" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
<PackageReference Include="ZB.MOM.WW.Health" /> <PackageReference Include="ZB.MOM.WW.Health" />
<PackageReference Include="ZB.MOM.WW.Health.Akka" /> <PackageReference Include="ZB.MOM.WW.Health.Akka" />
<PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" /> <PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" />