diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs new file mode 100644 index 0000000..8760764 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs @@ -0,0 +1,125 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// +/// Controls how the central alarm monitor selects between the MXAccess +/// alarm-manager subscription and the subtag-polling fallback, and +/// governs the failure-detection thresholds used when switching. +/// +public sealed class AlarmFallbackOptions +{ + /// + /// Selects the operating mode for the alarm-manager ↔ subtag fallback + /// mechanism. Accepted values (case-insensitive): + /// + /// Auto — use the alarm manager; switch to subtag polling + /// automatically when failures + /// are detected, and probe for failback. + /// ForceAlarmManager — always use the alarm manager; + /// never fall back. + /// ForceSubtag — always use subtag polling; + /// never try the alarm manager. + /// + /// Default is Auto. + /// + public string Mode { get; init; } = "Auto"; + + /// + /// Number of consecutive alarm-manager failures before the monitor + /// switches to subtag-polling fallback. Must be at least 1. Default 3. + /// + public int ConsecutiveFailureThreshold { get; init; } = 3; + + /// + /// How often (in seconds) the monitor sends a probe to the alarm manager + /// while operating in subtag-polling fallback mode, to detect recovery. + /// Must be at least 1. Default 30. + /// + public int FailbackProbeIntervalSeconds { get; init; } = 30; + + /// + /// Number of consecutive successful probes required before the monitor + /// considers the alarm manager recovered and switches back. Must be at + /// least 1. Default 3. + /// + public int FailbackStableProbes { get; init; } = 3; + + /// + /// Controls how the monitor discovers the set of objects to poll when + /// operating in subtag-polling fallback mode. + /// + public AlarmDiscoveryOptions Discovery { get; init; } = new(); + + /// + /// Configures the subtag names the monitor reads when polling alarm state + /// in subtag-fallback mode. + /// + public AlarmSubtagNameOptions Subtags { get; init; } = new(); +} + +/// +/// Governs how the alarm monitor discovers objects to include in subtag-polling +/// fallback mode. Either the Galaxy Repository query (when +/// is true) or an explicit +/// list must be supplied when +/// MxGateway:Alarms:Fallback:Mode is ForceSubtag. +/// +public sealed class AlarmDiscoveryOptions +{ + /// + /// When true the monitor queries the Galaxy Repository SQL database + /// to enumerate alarm objects for the configured area. Default true. + /// + public bool UseGalaxyRepository { get; init; } = true; + + /// + /// Galaxy area to scope the Repository query to. When empty the monitor + /// falls back to . Ignored when + /// is false. + /// + public string Area { get; init; } = string.Empty; + + /// + /// Explicit list of MXAccess attribute paths to include in subtag polling, + /// supplementing (or replacing, when is + /// false) the Repository-derived list. Default empty. + /// + public string[] IncludeAttributes { get; init; } = Array.Empty(); + + /// + /// Attribute paths to exclude from the Repository-derived poll list. + /// Ignored when is false. + /// Default empty. + /// + public string[] ExcludeAttributes { get; init; } = Array.Empty(); +} + +/// +/// Configures the subtag names read by the alarm monitor when it is operating +/// in subtag-polling fallback mode. Names are matched against MXAccess item +/// handles; validation against the live MXAccess attribute list occurs at +/// runtime, not at startup. +/// +public sealed class AlarmSubtagNameOptions +{ + /// + /// Subtag name for the active-alarm flag. Default active. + /// + public string Active { get; init; } = "active"; + + /// + /// Subtag name for the acknowledged flag. Default acked. + /// + public string Acked { get; init; } = "acked"; + + /// + /// Optional subtag name for the acknowledgement comment field. + /// When empty the feature is disabled. Verified against MXAccess at + /// runtime before use. Default empty. + /// + public string AckComment { get; init; } = string.Empty; + + /// + /// Subtag name for the alarm priority. Default priority. + /// + public string Priority { get; init; } = "priority"; +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs index 38563fa..565eac8 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs @@ -45,4 +45,12 @@ public sealed class AlarmsOptions /// the monitor floors it at 5 seconds. /// public int ReconcileIntervalSeconds { get; init; } = 30; + + /// + /// Configuration for the alarm-manager ↔ subtag fallback mechanism: + /// operating mode, failure-detection thresholds, discovery, and subtag + /// names. Defaults (Mode = "Auto") preserve behaviour when the section is + /// omitted from configuration. + /// + public AlarmFallbackOptions Fallback { get; init; } = new(); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index c228a5c..fb7ed3e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -231,6 +231,8 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase\Galaxy! shape)."); } + + ValidateAlarmFallback(options.Fallback, builder); + } + + private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder) + { + // Validate Mode is one of the recognised values (case-insensitive). + bool modeValid = Array.Exists( + ValidAlarmFallbackModes, + m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase)); + + if (!modeValid) + { + builder.Add( + $"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}')."); + } + + // ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list. + if (modeValid + && string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase) + && !fallback.Discovery.UseGalaxyRepository + && fallback.Discovery.IncludeAttributes.Length == 0) + { + builder.Add( + "MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list."); + } + + // Floor validation: numeric thresholds must be at least 1. + AddIfNotPositive( + fallback.ConsecutiveFailureThreshold, + "MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.", + builder); + AddIfNotPositive( + fallback.FailbackProbeIntervalSeconds, + "MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.", + builder); + AddIfNotPositive( + fallback.FailbackStableProbes, + "MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.", + builder); } private const int MinimumCertValidityYears = 1; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index 49d8165..eea5285 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -118,4 +118,181 @@ public sealed class GatewayOptionsValidatorTests ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } + + // ------------------------------------------------------------------------- + // AlarmFallbackOptions validation + // ------------------------------------------------------------------------- + + private static AlarmsOptions EnabledAlarmsWithFallback(AlarmFallbackOptions fallback) => new() + { + Enabled = true, + DefaultArea = "Galaxy", + Fallback = fallback, + }; + + private static GatewayOptions CloneWithAlarms(GatewayOptions source, AlarmsOptions alarms) + => new() + { + Authentication = source.Authentication, + Ldap = source.Ldap, + Worker = source.Worker, + Sessions = source.Sessions, + Events = source.Events, + Dashboard = source.Dashboard, + Protocol = source.Protocol, + Alarms = alarms, + Tls = source.Tls, + }; + + [Fact] + public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated() + { + // Even an invalid Mode is acceptable when Enabled = false. + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + new AlarmsOptions + { + Enabled = false, + Fallback = new AlarmFallbackOptions { Mode = "InvalidMode" }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig() + { + // Default AlarmFallbackOptions (Mode="Auto") must pass validation when alarms are enabled. + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions())); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData("Auto")] + [InlineData("ForceAlarmManager")] + [InlineData("ForceSubtag")] + [InlineData("auto")] + [InlineData("FORCESUBTAG")] + public void Validate_Succeeds_WhenAlarmsEnabled_RecognisedMode(string mode) + { + AlarmsOptions alarms = mode.Equals("ForceSubtag", StringComparison.OrdinalIgnoreCase) + // ForceSubtag needs either UseGalaxyRepository=true (default) or IncludeAttributes. + ? EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode }) + : EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode }); + GatewayOptions options = CloneWithAlarms(ValidOptions(), alarms); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_Fails_WhenAlarmsEnabled_InvalidMode() + { + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "InvalidMode" })); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Alarms:Fallback") && f.Contains("Mode")); + } + + [Fact] + public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes() + { + // ForceSubtag without galaxy repository and without IncludeAttributes must fail. + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions + { + Mode = "ForceSubtag", + Discovery = new AlarmDiscoveryOptions + { + UseGalaxyRepository = false, + IncludeAttributes = [], + }, + })); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("ForceSubtag") && f.Contains("Discovery")); + } + + [Fact] + public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes() + { + // ForceSubtag without galaxy repository is allowed when IncludeAttributes is non-empty. + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions + { + Mode = "ForceSubtag", + Discovery = new AlarmDiscoveryOptions + { + UseGalaxyRepository = false, + IncludeAttributes = ["attr1"], + }, + })); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository() + { + // ForceSubtag + UseGalaxyRepository=true (default) must pass even without IncludeAttributes. + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions + { + Mode = "ForceSubtag", + Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = true }, + })); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))] + [InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))] + public void Validate_Fails_WhenConsecutiveFailureThresholdBelowOne(int value, string keyPart) + { + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions { ConsecutiveFailureThreshold = value })); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains(keyPart)); + _ = keyPart; // suppress unused-param warning + } + + [Theory] + [InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))] + [InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))] + public void Validate_Fails_WhenFailbackProbeIntervalSecondsBelowOne(int value, string keyPart) + { + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackProbeIntervalSeconds = value })); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains(keyPart)); + _ = keyPart; + } + + [Theory] + [InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))] + [InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))] + public void Validate_Fails_WhenFailbackStableProbesBelowOne(int value, string keyPart) + { + GatewayOptions options = CloneWithAlarms( + ValidOptions(), + EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackStableProbes = value })); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains(keyPart)); + _ = keyPart; + } }