diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs new file mode 100644 index 00000000..d101741c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs @@ -0,0 +1,69 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.Communication; + +/// +/// Validates at startup (ValidateOnStart) so a +/// malformed "Communication" appsettings section fails fast at boot with a +/// key-naming message instead of surfacing later at first Ask/gRPC use. Every +/// timeout feeds a per-pattern Ask deadline or a gRPC keepalive/stream-lifetime +/// setting, and a zero/negative value there produces an opaque runtime failure +/// far from the offending config key. +/// +public sealed class CommunicationOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, CommunicationOptions options) + { + builder.RequireThat(options.DeploymentTimeout > TimeSpan.Zero, + $"Communication:DeploymentTimeout must be a positive duration (was {options.DeploymentTimeout})."); + + builder.RequireThat(options.LifecycleTimeout > TimeSpan.Zero, + $"Communication:LifecycleTimeout must be a positive duration (was {options.LifecycleTimeout})."); + + builder.RequireThat(options.ArtifactDeploymentTimeout > TimeSpan.Zero, + $"Communication:ArtifactDeploymentTimeout must be a positive duration (was {options.ArtifactDeploymentTimeout})."); + + builder.RequireThat(options.QueryTimeout > TimeSpan.Zero, + $"Communication:QueryTimeout must be a positive duration (was {options.QueryTimeout})."); + + builder.RequireThat(options.IntegrationTimeout > TimeSpan.Zero, + $"Communication:IntegrationTimeout must be a positive duration (was {options.IntegrationTimeout})."); + + builder.RequireThat(options.DebugViewTimeout > TimeSpan.Zero, + $"Communication:DebugViewTimeout must be a positive duration (was {options.DebugViewTimeout})."); + + builder.RequireThat(options.HealthReportTimeout > TimeSpan.Zero, + $"Communication:HealthReportTimeout must be a positive duration (was {options.HealthReportTimeout})."); + + builder.RequireThat(options.NotificationForwardTimeout > TimeSpan.Zero, + $"Communication:NotificationForwardTimeout must be a positive duration (was {options.NotificationForwardTimeout})."); + + builder.RequireThat(options.GrpcKeepAlivePingDelay > TimeSpan.Zero, + $"Communication:GrpcKeepAlivePingDelay must be a positive duration (was {options.GrpcKeepAlivePingDelay})."); + + builder.RequireThat(options.GrpcKeepAlivePingTimeout > TimeSpan.Zero, + $"Communication:GrpcKeepAlivePingTimeout must be a positive duration (was {options.GrpcKeepAlivePingTimeout})."); + + builder.RequireThat(options.GrpcMaxStreamLifetime > TimeSpan.Zero, + $"Communication:GrpcMaxStreamLifetime must be a positive duration (was {options.GrpcMaxStreamLifetime})."); + + builder.RequireThat(options.TransportHeartbeatInterval > TimeSpan.Zero, + $"Communication:TransportHeartbeatInterval must be a positive duration (was {options.TransportHeartbeatInterval})."); + + builder.RequireThat(options.ApplicationHeartbeatInterval > TimeSpan.Zero, + $"Communication:ApplicationHeartbeatInterval must be a positive duration (was {options.ApplicationHeartbeatInterval})."); + + builder.RequireThat(options.TransportFailureThreshold > TimeSpan.Zero, + $"Communication:TransportFailureThreshold must be a positive duration (was {options.TransportFailureThreshold})."); + + builder.RequireThat(options.PendingDeploymentTtl > TimeSpan.Zero, + $"Communication:PendingDeploymentTtl must be a positive duration (was {options.PendingDeploymentTtl})."); + + builder.RequireThat(options.PendingDeploymentPurgeInterval > TimeSpan.Zero, + $"Communication:PendingDeploymentPurgeInterval must be a positive duration (was {options.PendingDeploymentPurgeInterval})."); + + builder.RequireThat(options.GrpcMaxConcurrentStreams > 0, + $"Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams})."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs index 353ced49..dc9577f2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication; @@ -11,7 +13,10 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddCommunication(this IServiceCollection services) { services.AddOptions() - .BindConfiguration("Communication"); + .BindConfiguration("Communication") + .ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, CommunicationOptionsValidator>()); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj b/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj index 9deff0b9..69074da1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj @@ -24,6 +24,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs new file mode 100644 index 00000000..1379bddd --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs @@ -0,0 +1,39 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer; + +/// +/// Validates at startup (ValidateOnStart) so a +/// malformed "DataConnectionLayer" appsettings section fails fast at boot with a +/// key-naming message instead of surfacing later at runtime. The intervals drive +/// the reconnect/tag-resolution timers and the seed-read retry loop; a +/// zero/negative duration (or a non-positive SeedReadMaxAttempts) would +/// otherwise produce an opaque failure far from the offending config key. +/// +public sealed class DataConnectionOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, DataConnectionOptions options) + { + builder.RequireThat(options.ReconnectInterval > TimeSpan.Zero, + $"DataConnectionLayer:ReconnectInterval must be a positive duration (was {options.ReconnectInterval})."); + + builder.RequireThat(options.TagResolutionRetryInterval > TimeSpan.Zero, + $"DataConnectionLayer:TagResolutionRetryInterval must be a positive duration (was {options.TagResolutionRetryInterval})."); + + builder.RequireThat(options.WriteTimeout > TimeSpan.Zero, + $"DataConnectionLayer:WriteTimeout must be a positive duration (was {options.WriteTimeout})."); + + builder.RequireThat(options.SeedReadTimeout > TimeSpan.Zero, + $"DataConnectionLayer:SeedReadTimeout must be a positive duration (was {options.SeedReadTimeout})."); + + builder.RequireThat(options.StableConnectionThreshold > TimeSpan.Zero, + $"DataConnectionLayer:StableConnectionThreshold must be a positive duration (was {options.StableConnectionThreshold})."); + + builder.RequireThat(options.SeedReadRetryDelay > TimeSpan.Zero, + $"DataConnectionLayer:SeedReadRetryDelay must be a positive duration (was {options.SeedReadRetryDelay})."); + + builder.RequireThat(options.SeedReadMaxAttempts > 0, + $"DataConnectionLayer:SeedReadMaxAttempts must be positive (was {options.SeedReadMaxAttempts})."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs index 9954eb27..5f175071 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer; @@ -13,8 +15,14 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddDataConnectionLayer(this IServiceCollection services) { services.AddOptions() - .BindConfiguration("DataConnectionLayer"); + .BindConfiguration("DataConnectionLayer") + .ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, DataConnectionOptionsValidator>()); + // OpcUaGlobalOptions / MxGatewayGlobalOptions carry only string identity/path + // fields (no numeric or duration knobs), so there is nothing to range-validate + // — no dedicated validator is registered for them. services.AddOptions() .BindConfiguration("OpcUa"); diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj index 751f2fb1..775ace0d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj @@ -17,6 +17,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs new file mode 100644 index 00000000..2ce5f0ff --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; + +/// +/// Regression: timeouts feed per-pattern Ask +/// deadlines and gRPC keepalive/stream-lifetime settings; a zero/negative value +/// (or a non-positive GrpcMaxConcurrentStreams) must be rejected at startup +/// by an with a clear, key-naming message +/// rather than surfacing at first Ask/gRPC use. +/// +public class CommunicationOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(CommunicationOptions options) => + new CommunicationOptionsValidator().Validate(Options.DefaultName, options); + + [Fact] + public void DefaultOptions_AreValid() + { + var result = Validate(new CommunicationOptions()); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void ZeroDeploymentTimeout_IsRejected() + { + var result = Validate(new CommunicationOptions { DeploymentTimeout = TimeSpan.Zero }); + Assert.True(result.Failed); + Assert.Contains("DeploymentTimeout", result.FailureMessage); + } + + [Fact] + public void NonPositiveGrpcMaxConcurrentStreams_IsRejected() + { + var result = Validate(new CommunicationOptions { GrpcMaxConcurrentStreams = 0 }); + Assert.True(result.Failed); + Assert.Contains("GrpcMaxConcurrentStreams", result.FailureMessage); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs new file mode 100644 index 00000000..2fbe3c1a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; + +/// +/// Regression: intervals/timeouts drive the +/// DCL reconnect/tag-resolution timers and the seed-read retry loop; a +/// zero/negative duration (or a non-positive SeedReadMaxAttempts) must be +/// rejected at startup by an with a +/// clear, key-naming message rather than surfacing later at runtime. +/// +public class DataConnectionOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(DataConnectionOptions options) => + new DataConnectionOptionsValidator().Validate(Options.DefaultName, options); + + [Fact] + public void DefaultOptions_AreValid() + { + var result = Validate(new DataConnectionOptions()); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void ZeroReconnectInterval_IsRejected() + { + var result = Validate(new DataConnectionOptions { ReconnectInterval = TimeSpan.Zero }); + Assert.True(result.Failed); + Assert.Contains("ReconnectInterval", result.FailureMessage); + } + + [Fact] + public void NonPositiveSeedReadMaxAttempts_IsRejected() + { + var result = Validate(new DataConnectionOptions { SeedReadMaxAttempts = 0 }); + Assert.True(result.Failed); + Assert.Contains("SeedReadMaxAttempts", result.FailureMessage); + } +}