refactor: ScadaBridge validators onto OptionsValidatorBase (messages unchanged)

This commit is contained in:
Joseph Doherty
2026-06-01 18:56:04 -04:00
parent 9bca6aae61
commit aac59c9fae
8 changed files with 93 additions and 164 deletions
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.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 /// drop in-flight investigations, too long would defeat the partition-switch
/// purge's purpose. /// purge's purpose.
/// </summary> /// </summary>
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions> public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOptions>
{ {
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary> /// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
public const int MinRetentionDays = 30; public const int MinRetentionDays = 30;
@@ -28,43 +28,25 @@ public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
public const int MaxInboundMaxBytes = 16_777_216; public const int MaxInboundMaxBytes = 16_777_216;
/// <inheritdoc /> /// <inheritdoc />
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<string>(); 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) builder.RequireThat(
{ !(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
failures.Add( $"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " + $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
"must be > 0; it drives payload-summary truncation in audit writers.");
}
if (options.ErrorCapBytes < options.DefaultCapBytes) builder.RequireThat(
{ !(options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes),
failures.Add( $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " + $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
$"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);
} }
} }
@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
/// Registered with <c>ValidateOnStart()</c> so a bad <c>appsettings.json</c> /// Registered with <c>ValidateOnStart()</c> so a bad <c>appsettings.json</c>
/// fails fast at boot rather than failing far from the cause. /// fails fast at boot rather than failing far from the cause.
/// </summary> /// </summary>
public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions> public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
{ {
/// <summary>Split-brain resolver strategies safe for ScadaBridge's two-node clusters.</summary> /// <summary>Split-brain resolver strategies safe for ScadaBridge's two-node clusters.</summary>
private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase)
@@ -19,77 +19,51 @@ public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>
}; };
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <param name="name">Named options instance name (unused; all instances are validated identically).</param> /// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">The cluster options to validate.</param> /// <param name="options">The cluster options to validate.</param>
public ValidateOptionsResult Validate(string? name, ClusterOptions options) protected override void Validate(ValidationBuilder builder, ClusterOptions options)
{ {
var failures = new List<string>(); // 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) builder.RequireThat(
{ !string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
// CI-012: design doc states "both nodes are seed nodes — each node lists && AllowedStrategies.Contains(options.SplitBrainResolverStrategy),
// both itself and its partner" so a properly-configured deployment lists $"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
// two. Accepting a single-seed configuration silently defeats the $"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
// "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.");
}
if (string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy) builder.RequireThat(options.MinNrOfMembers == 1,
|| !AllowedStrategies.Contains(options.SplitBrainResolverStrategy)) $"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
{ "any other value blocks the cluster singleton after failover and halts all data collection.");
failures.Add(
$"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
$"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
}
if (options.MinNrOfMembers != 1) builder.RequireThat(options.StableAfter > TimeSpan.Zero,
{ "ClusterOptions.StableAfter must be a positive duration.");
failures.Add(
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
"any other value blocks the cluster singleton after failover and halts all data collection.");
}
if (options.StableAfter <= TimeSpan.Zero) builder.RequireThat(options.HeartbeatInterval > TimeSpan.Zero,
{ "ClusterOptions.HeartbeatInterval must be a positive duration.");
failures.Add("ClusterOptions.StableAfter must be a positive duration.");
}
if (options.HeartbeatInterval <= TimeSpan.Zero) builder.RequireThat(options.FailureDetectionThreshold > TimeSpan.Zero,
{ "ClusterOptions.FailureDetectionThreshold must be a positive duration.");
failures.Add("ClusterOptions.HeartbeatInterval must be a positive duration.");
}
if (options.FailureDetectionThreshold <= TimeSpan.Zero) builder.RequireThat(options.HeartbeatInterval < options.FailureDetectionThreshold,
{ $"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " +
failures.Add("ClusterOptions.FailureDetectionThreshold must be a positive duration."); $"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
} "declared unreachable before a heartbeat can arrive.");
if (options.HeartbeatInterval >= options.FailureDetectionThreshold) builder.RequireThat(options.DownIfAlone,
{ "ClusterOptions.DownIfAlone must be true for the keep-oldest resolver "
failures.Add( + "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the "
$"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " + + "oldest node can run as an isolated single-node cluster during a partition while the "
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " + + "younger node forms its own, producing two live clusters.");
"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;
} }
} }
@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
@@ -14,51 +14,34 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
/// <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:HealthMonitoring</c> section /// <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:HealthMonitoring</c> section
/// fails fast at boot with a clear, key-naming message. /// fails fast at boot with a clear, key-naming message.
/// </summary> /// </summary>
public sealed class HealthMonitoringOptionsValidator : IValidateOptions<HealthMonitoringOptions> public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase<HealthMonitoringOptions>
{ {
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <param name="name">Named options instance name (unused).</param> /// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">The health monitoring options to validate.</param> /// <param name="options">The health monitoring options to validate.</param>
public ValidateOptionsResult Validate(string? name, HealthMonitoringOptions options) protected override void Validate(ValidationBuilder builder, HealthMonitoringOptions options)
{ {
var failures = new List<string>(); 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) builder.RequireThat(options.OfflineTimeout > TimeSpan.Zero,
{ $"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " +
failures.Add( $"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence.");
$"ScadaBridge:HealthMonitoring:ReportInterval must be a positive duration " +
$"(was {options.ReportInterval}); it is used directly as a PeriodicTimer period.");
}
if (options.OfflineTimeout <= TimeSpan.Zero) builder.RequireThat(options.CentralOfflineTimeout > TimeSpan.Zero,
{ $"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " +
failures.Add( $"(was {options.CentralOfflineTimeout}).");
$"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " +
$"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence.");
}
if (options.CentralOfflineTimeout <= TimeSpan.Zero) builder.RequireThat(
{ !(options.OfflineTimeout > TimeSpan.Zero
failures.Add( && options.CentralOfflineTimeout > TimeSpan.Zero
$"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " + && options.CentralOfflineTimeout < options.OfflineTimeout),
$"(was {options.CentralOfflineTimeout})."); $"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 " +
if (options.OfflineTimeout > TimeSpan.Zero "least as much offline grace as a real site.");
&& 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;
} }
} }
@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Security; 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. /// minimum-byte length contract co-located with the type that enforces it.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class SecurityOptionsValidator : IValidateOptions<SecurityOptions> public sealed class SecurityOptionsValidator : OptionsValidatorBase<SecurityOptions>
{ {
/// <summary> /// <summary>
/// The configuration section name <see cref="SecurityOptions"/> is bound /// The configuration section name <see cref="SecurityOptions"/> is bound
@@ -41,30 +41,16 @@ public sealed class SecurityOptionsValidator : IValidateOptions<SecurityOptions>
public const string ConfigSectionName = "Security"; public const string ConfigSectionName = "Security";
/// <inheritdoc /> /// <inheritdoc />
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<string>(); builder.RequireThat(!string.IsNullOrWhiteSpace(options.LdapSearchBase),
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " +
if (string.IsNullOrWhiteSpace(options.LdapServer)) "but was empty or whitespace — set it to the search-base DN " +
{ "(e.g. \"dc=example,dc=com\").");
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);
} }
} }
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authorization" /> <PackageReference Include="Microsoft.AspNetCore.Authorization" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" /> <PackageReference Include="Novell.Directory.Ldap.NETStandard" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>