diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs index d572311..814e9ac 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration; @@ -6,15 +7,14 @@ public static class GatewayConfigurationServiceCollectionExtensions { /// Registers gateway configuration services in the dependency injection container. /// The service collection. + /// The configuration to bind gateway options from. /// The service collection for chaining. - public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services) + public static IServiceCollection AddGatewayConfiguration( + this IServiceCollection services, IConfiguration configuration) { - services - .AddOptions() - .BindConfiguration(GatewayOptions.SectionName) - .ValidateOnStart(); + services.AddValidatedOptions( + configuration, GatewayOptions.SectionName); - services.AddSingleton, GatewayOptionsValidator>(); services.AddSingleton(); return services; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 19994bc..fd104ca 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -1,9 +1,9 @@ -using Microsoft.Extensions.Options; +using ZB.MOM.WW.Configuration; using ZB.MOM.WW.MxGateway.Contracts; namespace ZB.MOM.WW.MxGateway.Server.Configuration; -public sealed class GatewayOptionsValidator : IValidateOptions +public sealed class GatewayOptionsValidator : OptionsValidatorBase { private const int MinimumMaxMessageBytes = 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; @@ -11,33 +11,26 @@ public sealed class GatewayOptionsValidator : IValidateOptions /// /// Validates gateway configuration options. /// - /// Options name. + /// The accumulator to record failures on. /// Gateway options to validate. - /// Validation result. - public ValidateOptionsResult Validate(string? name, GatewayOptions options) + protected override void Validate(ValidationBuilder builder, GatewayOptions options) { - List failures = []; - - ValidateAuthentication(options.Authentication, failures); - ValidateLdap(options.Ldap, failures); - ValidateWorker(options.Worker, failures); - ValidateSessions(options.Sessions, failures); - ValidateEvents(options.Events, failures); - ValidateDashboard(options.Dashboard, failures); - ValidateProtocol(options.Protocol, failures); - ValidateAlarms(options.Alarms, failures); - ValidateTls(options.Tls, failures); - - return failures.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(failures); + ValidateAuthentication(options.Authentication, builder); + ValidateLdap(options.Ldap, builder); + ValidateWorker(options.Worker, builder); + ValidateSessions(options.Sessions, builder); + ValidateEvents(options.Events, builder); + ValidateDashboard(options.Dashboard, builder); + ValidateProtocol(options.Protocol, builder); + ValidateAlarms(options.Alarms, builder); + ValidateTls(options.Tls, builder); } - private static void ValidateAuthentication(AuthenticationOptions options, List failures) + private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder) { if (!Enum.IsDefined(options.Mode)) { - failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode."); + builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode."); return; } @@ -46,67 +39,67 @@ public sealed class GatewayOptionsValidator : IValidateOptions AddIfBlank( options.SqlitePath, "MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.", - failures); + builder); AddIfInvalidPath( options.SqlitePath, "MxGateway:Authentication:SqlitePath must be a valid filesystem path.", - failures); + builder); AddIfBlank( options.PepperSecretName, "MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.", - failures); + builder); } } - private static void ValidateLdap(LdapOptions options, List failures) + private static void ValidateLdap(LdapOptions options, ValidationBuilder builder) { if (!options.Enabled) { return; } - AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures); - AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures); + AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder); + AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder); AddIfBlank( options.ServiceAccountDn, "MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.", - failures); + builder); AddIfBlank( options.ServiceAccountPassword, "MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.", - failures); + builder); AddIfBlank( options.UserNameAttribute, "MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.", - failures); + builder); AddIfBlank( options.DisplayNameAttribute, "MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.", - failures); + builder); AddIfBlank( options.GroupAttribute, "MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.", - failures); - AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures); + builder); + AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", builder); if (!options.UseTls && !options.AllowInsecureLdap) { - failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false."); + builder.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false."); } } - private static void ValidateWorker(WorkerOptions options, List failures) + private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder) { - AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures); + AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder); AddIfInvalidPath( options.ExecutablePath, "MxGateway:Worker:ExecutablePath must be a valid filesystem path.", - failures); + builder); if (!string.IsNullOrWhiteSpace(options.ExecutablePath) && !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase)) { - failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file."); + builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file."); } if (!string.IsNullOrWhiteSpace(options.WorkingDirectory)) @@ -114,94 +107,94 @@ public sealed class GatewayOptionsValidator : IValidateOptions AddIfInvalidPath( options.WorkingDirectory, "MxGateway:Worker:WorkingDirectory must be a valid filesystem path.", - failures); + builder); } if (!Enum.IsDefined(options.RequiredArchitecture)) { - failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture."); + builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture."); } AddIfNotPositive( options.StartupTimeoutSeconds, "MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.", - failures); + builder); AddIfNotPositive( options.StartupProbeRetryAttempts, "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.", - failures); + builder); AddIfNotPositive( options.StartupProbeRetryDelayMilliseconds, "MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.", - failures); + builder); AddIfNotPositive( options.PipeConnectAttemptTimeoutMilliseconds, "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.", - failures); + builder); AddIfNotPositive( options.ShutdownTimeoutSeconds, "MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.", - failures); + builder); AddIfNotPositive( options.HeartbeatIntervalSeconds, "MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.", - failures); + builder); AddIfNotPositive( options.HeartbeatGraceSeconds, "MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.", - failures); + builder); if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds) { - failures.Add( + builder.Add( "MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds."); } if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) { - failures.Add( + builder.Add( $"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); } } - private static void ValidateSessions(SessionOptions options, List failures) + private static void ValidateSessions(SessionOptions options, ValidationBuilder builder) { AddIfNotPositive( options.DefaultCommandTimeoutSeconds, "MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.", - failures); - AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures); + builder); + AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder); AddIfNotPositive( options.MaxPendingCommandsPerSession, "MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.", - failures); + builder); AddIfNotPositive( options.DefaultLeaseSeconds, "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.", - failures); + builder); AddIfNotPositive( options.LeaseSweepIntervalSeconds, "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.", - failures); + builder); if (options.AllowMultipleEventSubscribers) { - failures.Add( + builder.Add( "MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented."); } } - private static void ValidateEvents(EventOptions options, List failures) + private static void ValidateEvents(EventOptions options, ValidationBuilder builder) { - AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures); + AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder); if (!Enum.IsDefined(options.BackpressurePolicy)) { - failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy."); + builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy."); } } - private static void ValidateDashboard(DashboardOptions options, List failures) + private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder) { // GroupToRole shape is validated even when the dashboard is disabled so // misconfiguration surfaces at startup; emptiness is allowed, with the @@ -212,13 +205,13 @@ public sealed class GatewayOptionsValidator : IValidateOptions { if (string.IsNullOrWhiteSpace(entry.Key)) { - failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank."); + builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank."); } if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal) && !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal)) { - failures.Add( + builder.Add( $"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'."); } } @@ -226,18 +219,18 @@ public sealed class GatewayOptionsValidator : IValidateOptions AddIfNotPositive( options.SnapshotIntervalMilliseconds, "MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.", - failures); + builder); AddIfNegative( options.RecentFaultLimit, "MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.", - failures); + builder); AddIfNegative( options.RecentSessionLimit, "MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.", - failures); + builder); } - private static void ValidateAlarms(AlarmsOptions options, List failures) + private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder) { if (!options.Enabled) { @@ -251,14 +244,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions if (string.IsNullOrWhiteSpace(options.SubscriptionExpression) && string.IsNullOrWhiteSpace(options.DefaultArea)) { - failures.Add( + builder.Add( "MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true."); } if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression) && !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal)) { - failures.Add( + builder.Add( @"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\\Galaxy! shape)."); } } @@ -266,11 +259,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions private const int MinimumCertValidityYears = 1; private const int MaximumCertValidityYears = 100; - private static void ValidateTls(TlsOptions options, List failures) + private static void ValidateTls(TlsOptions options, ValidationBuilder builder) { if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears) { - failures.Add( + builder.Add( $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); } @@ -278,61 +271,52 @@ public sealed class GatewayOptionsValidator : IValidateOptions AddIfBlank( options.SelfSignedCertPath, "MxGateway:Tls:SelfSignedCertPath must not be blank.", - failures); + builder); AddIfInvalidPath( options.SelfSignedCertPath, "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", - failures); + builder); foreach (string dns in options.AdditionalDnsNames) { if (string.IsNullOrWhiteSpace(dns)) { - failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank."); + builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank."); } } } - private static void ValidateProtocol(ProtocolOptions options, List failures) + private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder) { if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion) { - failures.Add( + builder.Add( $"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}."); } if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) { - failures.Add( + builder.Add( $"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); } } - private static void AddIfBlank(string? value, string message, List failures) + private static void AddIfBlank(string? value, string message, ValidationBuilder builder) { - if (string.IsNullOrWhiteSpace(value)) - { - failures.Add(message); - } + builder.RequireThat(!string.IsNullOrWhiteSpace(value), message); } - private static void AddIfNotPositive(int value, string message, List failures) + private static void AddIfNotPositive(int value, string message, ValidationBuilder builder) { - if (value <= 0) - { - failures.Add(message); - } + builder.RequireThat(value > 0, message); } - private static void AddIfNegative(int value, string message, List failures) + private static void AddIfNegative(int value, string message, ValidationBuilder builder) { - if (value < 0) - { - failures.Add(message); - } + builder.RequireThat(value >= 0, message); } - private static void AddIfInvalidPath(string? value, string message, List failures) + private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder) { if (string.IsNullOrWhiteSpace(value)) { @@ -345,15 +329,15 @@ public sealed class GatewayOptionsValidator : IValidateOptions } catch (ArgumentException) { - failures.Add(message); + builder.Add(message); } catch (NotSupportedException) { - failures.Add(message); + builder.Add(message); } catch (PathTooLongException) { - failures.Add(message); + builder.Add(message); } } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index 3483d37..60abdc2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -65,7 +65,7 @@ public static class GatewayApplication builder.AddZbSerilog(o => o.ServiceName = "mxgateway"); - builder.Services.AddGatewayConfiguration(); + builder.Services.AddGatewayConfiguration(builder.Configuration); builder.Services.AddSqliteAuthStore(); builder.Services.AddGatewayGrpcAuthorization(); builder.Services.AddHealthChecks() diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj index 855acc4..b36e4a2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj +++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj @@ -6,6 +6,7 @@ + diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs index fcb4af9..2a5aac4 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -132,7 +132,7 @@ public sealed class GatewayOptionsTests ServiceCollection services = new(); services.AddSingleton(configuration); - services.AddGatewayConfiguration(); + services.AddGatewayConfiguration(configuration); return services.BuildServiceProvider(validateScopes: true); } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs index 4cecdc0..173d4b4 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs @@ -245,7 +245,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable ServiceCollection services = new(); services.AddSingleton(configuration); - services.AddGatewayConfiguration(); + services.AddGatewayConfiguration(configuration); services.AddSqliteAuthStore(); return services.BuildServiceProvider(validateScopes: true); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs index fd8076d..985ef00 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs @@ -188,7 +188,7 @@ public sealed class SqliteAuthStoreTests : IDisposable ServiceCollection services = new(); services.AddSingleton(configuration); - services.AddGatewayConfiguration(); + services.AddGatewayConfiguration(configuration); services.AddSqliteAuthStore(); return services.BuildServiceProvider(validateScopes: true);