merge(r2): r2-plan08

# Conflicts:
#	src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs
This commit is contained in:
Joseph Doherty
2026-07-13 11:10:15 -04:00
42 changed files with 1094 additions and 79 deletions
@@ -0,0 +1,28 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Validates <see cref="DatabaseOptions"/> at host startup (arch-review 08
/// round 2 NF4). All three connection settings are nullable and role-dependent
/// (central needs the SQL Server strings, a site needs only the SQLite path),
/// so a <c>null</c> value is valid. A present-but-whitespace value is not — it
/// signals a mis-set config key that would fail opaquely at first DB use.
/// </summary>
public sealed class DatabaseOptionsValidator : OptionsValidatorBase<DatabaseOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, DatabaseOptions options)
{
RequireNotWhitespace(builder, options.ConfigurationDb, nameof(DatabaseOptions.ConfigurationDb));
RequireNotWhitespace(builder, options.MachineDataDb, nameof(DatabaseOptions.MachineDataDb));
RequireNotWhitespace(builder, options.SiteDbPath, nameof(DatabaseOptions.SiteDbPath));
}
// null is valid (role-dependent); reject only a present-but-blank value.
private static void RequireNotWhitespace(ValidationBuilder builder, string? value, string field)
{
builder.RequireThat(value is null || !string.IsNullOrWhiteSpace(value),
$"ScadaBridge:Database:{field} is set but blank — either remove it or give it a real value.");
}
}
@@ -0,0 +1,27 @@
using Serilog.Events;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Validates <see cref="LoggingOptions"/> at host startup (arch-review 08 round
/// 2 NF4). <see cref="LoggingOptions.MinimumLevel"/> is parsed into a Serilog
/// <see cref="LogEventLevel"/> by <c>LoggerConfigurationFactory.ParseLevel</c>,
/// which silently falls back to <c>Information</c> for an unrecognised value.
/// This validator surfaces that misconfiguration at boot instead: a present,
/// non-blank value must name a real level (Verbose/Debug/Information/Warning/
/// Error/Fatal, case-insensitive). A null/blank value is treated as "unset"
/// (defaults to Information), mirroring the parser.
/// </summary>
public sealed class LoggingOptionsValidator : OptionsValidatorBase<LoggingOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, LoggingOptions options)
{
builder.RequireThat(
string.IsNullOrWhiteSpace(options.MinimumLevel)
|| Enum.TryParse<LogEventLevel>(options.MinimumLevel, ignoreCase: true, out _),
$"ScadaBridge:Logging:{nameof(LoggingOptions.MinimumLevel)} ('{options.MinimumLevel}') " +
$"is not a recognised Serilog level. Valid: {string.Join(", ", Enum.GetNames<LogEventLevel>())}.");
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Validates <see cref="NodeOptions"/> at host startup (arch-review 08 round 2
/// NF4). The headline invariant: <see cref="NodeOptions.NodeName"/> must be
/// non-empty — an empty value normalises to a NULL <c>SourceNode</c> audit
/// column, silently degrading the audit trail (the wonder-app-vd03 failure
/// mode), so the host must fail fast at boot instead. Ports are bounded to the
/// TCP range; <c>0</c> stays valid because it requests a dynamically-assigned
/// port (CompositionRootTests rely on this).
/// </summary>
public sealed class NodeOptionsValidator : OptionsValidatorBase<NodeOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, NodeOptions options)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(options.NodeName),
$"ScadaBridge:Node:{nameof(NodeOptions.NodeName)} must be a non-empty label " +
"— an empty node name stamps the SourceNode audit column NULL.");
RequirePort(builder, options.RemotingPort, nameof(NodeOptions.RemotingPort));
RequirePort(builder, options.GrpcPort, nameof(NodeOptions.GrpcPort));
RequirePort(builder, options.MetricsPort, nameof(NodeOptions.MetricsPort));
}
// 0 stays valid (dynamic-port request); reject only out-of-TCP-range values.
private static void RequirePort(ValidationBuilder builder, int port, string field)
{
builder.RequireThat(port is >= 0 and <= 65_535,
$"ScadaBridge:Node:{field} ({port}) must be in [0, 65535].");
}
}
@@ -137,7 +137,21 @@ public static class SiteServiceRegistration
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SiteRuntimeOptions>, SiteRuntimeOptionsValidator>());
services.Configure<DataConnectionOptions>(config.GetSection("ScadaBridge:DataConnection"));
// OperationTrackingOptions for the site-local cached-call tracking store
// (registered by AddSiteRuntime). Bind + eagerly validate here — the store
// opens its SQLite connection string at construction, so a blank connection
// string should fail the host at boot with a key-naming message rather than
// throw opaquely on first resolve (arch-review 08r2 NF4/T8).
services.AddOptions<SiteRuntime.Tracking.OperationTrackingOptions>()
.Bind(config.GetSection("ScadaBridge:OperationTracking"))
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SiteRuntime.Tracking.OperationTrackingOptions>,
SiteRuntime.Tracking.OperationTrackingOptionsValidator>());
// NF8: DataConnectionOptions is now bound by AddDataConnectionLayer() itself
// (canonical ScadaBridge:DataConnection section) — the duplicate Host binding
// that used to mask the SCE's wrong section name is deleted.
services.AddOptions<StoreAndForwardOptions>()
.Bind(config.GetSection("ScadaBridge:StoreAndForward"))
@@ -157,19 +171,38 @@ public static class SiteServiceRegistration
/// <param name="config">Application configuration supplying the option values.</param>
public static void BindSharedOptions(IServiceCollection services, IConfiguration config)
{
services.Configure<NodeOptions>(config.GetSection("ScadaBridge:Node"));
// Bind + eagerly validate: an empty NodeName would stamp the SourceNode audit
// column NULL, so fail the host at boot with a key-naming message instead of
// silently degrading the audit trail (arch-review 08r2 NF4).
services.AddOptions<NodeOptions>().Bind(config.GetSection("ScadaBridge:Node")).ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<NodeOptions>, NodeOptionsValidator>());
// Bind + eagerly validate: ClusterOptionsValidator is registered (TryAddEnumerable)
// by the ClusterInfrastructure module, so chaining ValidateOnStart() here makes a bad
// ScadaBridge:Cluster section fail fast at host build instead of lazily on first resolve.
services.AddOptions<ClusterOptions>().Bind(config.GetSection("ScadaBridge:Cluster")).ValidateOnStart();
services.Configure<DatabaseOptions>(config.GetSection("ScadaBridge:Database"));
services.Configure<CommunicationOptions>(config.GetSection("ScadaBridge:Communication"));
// Bind + eagerly validate: a present-but-blank connection setting fails fast
// here rather than opaquely at first DB use (arch-review 08r2 NF4).
services.AddOptions<DatabaseOptions>().Bind(config.GetSection("ScadaBridge:Database")).ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>());
// NF8: CommunicationOptions is now bound by AddCommunication() itself (canonical
// ScadaBridge:Communication section) — the duplicate Host binding that used to mask
// the SCE's wrong section name is deleted.
// Bind + eagerly validate: HealthMonitoringOptionsValidator is registered (TryAddEnumerable)
// by the HealthMonitoring module, so chaining ValidateOnStart() here makes a bad
// ScadaBridge:HealthMonitoring section fail fast at host build instead of lazily on first resolve.
services.AddOptions<HealthMonitoringOptions>().Bind(config.GetSection("ScadaBridge:HealthMonitoring")).ValidateOnStart();
services.Configure<NotificationOptions>(config.GetSection("ScadaBridge:Notification"));
services.Configure<LoggingOptions>(config.GetSection("ScadaBridge:Logging"));
// NF8: NotificationOptions is bound by AddNotificationService() (central-only,
// canonical ScadaBridge:Notification section). Sites don't deliver notifications
// and have no IOptions<NotificationOptions> consumer, so the redundant Host
// duplicate that bound it on both paths is deleted.
// Bind + eagerly validate: an unrecognised MinimumLevel would silently fall back
// to Information; fail fast at boot so the operator's intended floor is honoured
// (arch-review 08r2 NF4).
services.AddOptions<LoggingOptions>().Bind(config.GetSection("ScadaBridge:Logging")).ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<LoggingOptions>, LoggingOptionsValidator>());
// Audit Log — exposes ScadaBridge:Node:NodeName to downstream audit
// writers so they can stamp the SourceNode column. Registered here in