diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs index db573248..f07e813c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; @@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; /// drop in-flight investigations, too long would defeat the partition-switch /// purge's purpose. /// -public sealed class AuditLogOptionsValidator : IValidateOptions +public sealed class AuditLogOptionsValidator : OptionsValidatorBase { /// Inclusive lower bound for . public const int MinRetentionDays = 30; @@ -28,43 +28,25 @@ public sealed class AuditLogOptionsValidator : IValidateOptions public const int MaxInboundMaxBytes = 16_777_216; /// - public ValidateOptionsResult Validate(string? name, AuditLogOptions options) + protected override void Validate(ValidationBuilder builder, AuditLogOptions options) { - ArgumentNullException.ThrowIfNull(options); + builder.RequireThat(options.DefaultCapBytes > 0, + $"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " + + "must be > 0; it drives payload-summary truncation in audit writers."); - var failures = new List(); + builder.RequireThat(options.ErrorCapBytes >= options.DefaultCapBytes, + $"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " + + $"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " + + "the error-row cap is intended to capture more detail than the happy-path summary."); - if (options.DefaultCapBytes <= 0) - { - failures.Add( - $"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " + - "must be > 0; it drives payload-summary truncation in audit writers."); - } + builder.RequireThat( + !(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays), + $"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " + + $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days."); - if (options.ErrorCapBytes < options.DefaultCapBytes) - { - failures.Add( - $"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " + - $"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " + - "the error-row cap is intended to capture more detail than the happy-path summary."); - } - - if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays) - { - failures.Add( - $"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " + - $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days."); - } - - if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes) - { - failures.Add( - $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " + - $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes."); - } - - return failures.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(failures); + builder.RequireThat( + !(options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes), + $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " + + $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes."); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj index e470c56e..83783ee9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj @@ -16,6 +16,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs index 39bfcf6b..9e4b6290 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; @@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; /// Registered with ValidateOnStart() so a bad appsettings.json /// fails fast at boot rather than failing far from the cause. /// -public sealed class ClusterOptionsValidator : IValidateOptions +public sealed class ClusterOptionsValidator : OptionsValidatorBase { /// Split-brain resolver strategies safe for ScadaBridge's two-node clusters. private static readonly HashSet AllowedStrategies = new(StringComparer.OrdinalIgnoreCase) @@ -19,77 +19,51 @@ public sealed class ClusterOptionsValidator : IValidateOptions }; /// - /// Validates the cluster options, returning a failure result if any critical settings are misconfigured. + /// Validates the cluster options, recording a failure if any critical settings are misconfigured. /// - /// Named options instance name (unused; all instances are validated identically). + /// The accumulator to record failures on. /// The cluster options to validate. - public ValidateOptionsResult Validate(string? name, ClusterOptions options) + protected override void Validate(ValidationBuilder builder, ClusterOptions options) { - var failures = new List(); + // CI-012: design doc states "both nodes are seed nodes — each node lists + // both itself and its partner" so a properly-configured deployment lists + // two. Accepting a single-seed configuration silently defeats the + // "no startup ordering dependency" guarantee called out by + // Component-ClusterInfrastructure.md (Node Configuration). + builder.RequireThat(options.SeedNodes is not null && options.SeedNodes.Count >= 2, + "ClusterOptions.SeedNodes must contain at least 2 seed nodes " + + "(Component-ClusterInfrastructure.md → Node Configuration: " + + "both nodes are seed nodes); a single-seed configuration defeats " + + "the no-startup-ordering-dependency guarantee."); - if (options.SeedNodes is null || options.SeedNodes.Count < 2) - { - // CI-012: design doc states "both nodes are seed nodes — each node lists - // both itself and its partner" so a properly-configured deployment lists - // two. Accepting a single-seed configuration silently defeats the - // "no startup ordering dependency" guarantee called out by - // Component-ClusterInfrastructure.md (Node Configuration). - failures.Add( - "ClusterOptions.SeedNodes must contain at least 2 seed nodes " - + "(Component-ClusterInfrastructure.md → Node Configuration: " - + "both nodes are seed nodes); a single-seed configuration defeats " - + "the no-startup-ordering-dependency guarantee."); - } + builder.RequireThat( + !string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy) + && AllowedStrategies.Contains(options.SplitBrainResolverStrategy), + $"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " + + $"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition."); - if (string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy) - || !AllowedStrategies.Contains(options.SplitBrainResolverStrategy)) - { - failures.Add( - $"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " + - $"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition."); - } + builder.RequireThat(options.MinNrOfMembers == 1, + $"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " + + "any other value blocks the cluster singleton after failover and halts all data collection."); - if (options.MinNrOfMembers != 1) - { - failures.Add( - $"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " + - "any other value blocks the cluster singleton after failover and halts all data collection."); - } + builder.RequireThat(options.StableAfter > TimeSpan.Zero, + "ClusterOptions.StableAfter must be a positive duration."); - if (options.StableAfter <= TimeSpan.Zero) - { - failures.Add("ClusterOptions.StableAfter must be a positive duration."); - } + builder.RequireThat(options.HeartbeatInterval > TimeSpan.Zero, + "ClusterOptions.HeartbeatInterval must be a positive duration."); - if (options.HeartbeatInterval <= TimeSpan.Zero) - { - failures.Add("ClusterOptions.HeartbeatInterval must be a positive duration."); - } + builder.RequireThat(options.FailureDetectionThreshold > TimeSpan.Zero, + "ClusterOptions.FailureDetectionThreshold must be a positive duration."); - if (options.FailureDetectionThreshold <= TimeSpan.Zero) - { - failures.Add("ClusterOptions.FailureDetectionThreshold must be a positive duration."); - } + builder.RequireThat(options.HeartbeatInterval < options.FailureDetectionThreshold, + $"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " + + $"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " + + "declared unreachable before a heartbeat can arrive."); - if (options.HeartbeatInterval >= options.FailureDetectionThreshold) - { - failures.Add( - $"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " + - $"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " + - "declared unreachable before a heartbeat can arrive."); - } - - if (!options.DownIfAlone) - { - failures.Add( - "ClusterOptions.DownIfAlone must be true for the keep-oldest resolver " - + "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the " - + "oldest node can run as an isolated single-node cluster during a partition while the " - + "younger node forms its own, producing two live clusters."); - } - - return failures.Count > 0 - ? ValidateOptionsResult.Fail(failures) - : ValidateOptionsResult.Success; + builder.RequireThat(options.DownIfAlone, + "ClusterOptions.DownIfAlone must be true for the keep-oldest resolver " + + "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the " + + "oldest node can run as an isolated single-node cluster during a partition while the " + + "younger node forms its own, producing two live clusters."); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.csproj b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.csproj index 40c068eb..ff100d6e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.csproj @@ -10,6 +10,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs index 02571ef1..f9a27a49 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring; @@ -14,51 +14,34 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring; /// ValidateOnStart() so a bad ScadaBridge:HealthMonitoring section /// fails fast at boot with a clear, key-naming message. /// -public sealed class HealthMonitoringOptionsValidator : IValidateOptions +public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase { /// - /// Validates the health monitoring options, returning a failure result if any interval values are non-positive. + /// Validates the health monitoring options, recording a failure if any interval values are non-positive. /// - /// Named options instance name (unused). + /// The accumulator to record failures on. /// The health monitoring options to validate. - public ValidateOptionsResult Validate(string? name, HealthMonitoringOptions options) + protected override void Validate(ValidationBuilder builder, HealthMonitoringOptions options) { - var failures = new List(); + builder.RequireThat(options.ReportInterval > TimeSpan.Zero, + $"ScadaBridge:HealthMonitoring:ReportInterval must be a positive duration " + + $"(was {options.ReportInterval}); it is used directly as a PeriodicTimer period."); - if (options.ReportInterval <= TimeSpan.Zero) - { - failures.Add( - $"ScadaBridge:HealthMonitoring:ReportInterval must be a positive duration " + - $"(was {options.ReportInterval}); it is used directly as a PeriodicTimer period."); - } + builder.RequireThat(options.OfflineTimeout > TimeSpan.Zero, + $"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " + + $"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence."); - if (options.OfflineTimeout <= TimeSpan.Zero) - { - failures.Add( - $"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " + - $"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence."); - } + builder.RequireThat(options.CentralOfflineTimeout > TimeSpan.Zero, + $"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " + + $"(was {options.CentralOfflineTimeout})."); - if (options.CentralOfflineTimeout <= TimeSpan.Zero) - { - failures.Add( - $"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " + - $"(was {options.CentralOfflineTimeout})."); - } - - if (options.OfflineTimeout > TimeSpan.Zero - && options.CentralOfflineTimeout > TimeSpan.Zero - && options.CentralOfflineTimeout < options.OfflineTimeout) - { - failures.Add( - $"ScadaBridge:HealthMonitoring:CentralOfflineTimeout ({options.CentralOfflineTimeout}) " + - $"must be >= OfflineTimeout ({options.OfflineTimeout}): the synthetic 'central' site has " + - "no heartbeat source and is fed only by the slower self-report loop, so it needs at " + - "least as much offline grace as a real site."); - } - - return failures.Count > 0 - ? ValidateOptionsResult.Fail(failures) - : ValidateOptionsResult.Success; + builder.RequireThat( + !(options.OfflineTimeout > TimeSpan.Zero + && options.CentralOfflineTimeout > TimeSpan.Zero + && options.CentralOfflineTimeout < options.OfflineTimeout), + $"ScadaBridge:HealthMonitoring:CentralOfflineTimeout ({options.CentralOfflineTimeout}) " + + $"must be >= OfflineTimeout ({options.OfflineTimeout}): the synthetic 'central' site has " + + "no heartbeat source and is fed only by the slower self-report loop, so it needs at " + + "least as much offline grace as a real site."); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj index 7555c1d8..0bfca876 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj @@ -12,6 +12,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs index 3c1fe144..d47a764d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptionsValidator.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.ScadaBridge.Security; @@ -29,7 +29,7 @@ namespace ZB.MOM.WW.ScadaBridge.Security; /// minimum-byte length contract co-located with the type that enforces it. /// /// -public sealed class SecurityOptionsValidator : IValidateOptions +public sealed class SecurityOptionsValidator : OptionsValidatorBase { /// /// The configuration section name is bound @@ -41,30 +41,16 @@ public sealed class SecurityOptionsValidator : IValidateOptions public const string ConfigSectionName = "Security"; /// - public ValidateOptionsResult Validate(string? name, SecurityOptions options) + protected override void Validate(ValidationBuilder builder, SecurityOptions options) { - ArgumentNullException.ThrowIfNull(options); + builder.RequireThat(!string.IsNullOrWhiteSpace(options.LdapServer), + $"{ConfigSectionName}:{nameof(SecurityOptions.LdapServer)} is required " + + "but was empty or whitespace — set it to the LDAP server hostname or IP " + + "(e.g. \"ldap.example.com\")."); - var failures = new List(); - - if (string.IsNullOrWhiteSpace(options.LdapServer)) - { - failures.Add( - $"{ConfigSectionName}:{nameof(SecurityOptions.LdapServer)} is required " + - "but was empty or whitespace — set it to the LDAP server hostname or IP " + - "(e.g. \"ldap.example.com\")."); - } - - if (string.IsNullOrWhiteSpace(options.LdapSearchBase)) - { - failures.Add( - $"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " + - "but was empty or whitespace — set it to the search-base DN " + - "(e.g. \"dc=example,dc=com\")."); - } - - return failures.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(failures); + builder.RequireThat(!string.IsNullOrWhiteSpace(options.LdapSearchBase), + $"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " + + "but was empty or whitespace — set it to the search-base DN " + + "(e.g. \"dc=example,dc=com\")."); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj index 6cf3e155..dabf2f2c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj @@ -14,6 +14,7 @@ +