server(alarms): AlarmFallbackOptions + ForceSubtag/threshold validation (Task 10)
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmFallbackOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Selects the operating mode for the alarm-manager ↔ subtag fallback
|
||||||
|
/// mechanism. Accepted values (case-insensitive):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>Auto</c> — use the alarm manager; switch to subtag polling
|
||||||
|
/// automatically when <see cref="ConsecutiveFailureThreshold"/> failures
|
||||||
|
/// are detected, and probe for failback.</item>
|
||||||
|
/// <item><c>ForceAlarmManager</c> — always use the alarm manager;
|
||||||
|
/// never fall back.</item>
|
||||||
|
/// <item><c>ForceSubtag</c> — always use subtag polling;
|
||||||
|
/// never try the alarm manager.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Default is <c>Auto</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Mode { get; init; } = "Auto";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of consecutive alarm-manager failures before the monitor
|
||||||
|
/// switches to subtag-polling fallback. Must be at least 1. Default 3.
|
||||||
|
/// </summary>
|
||||||
|
public int ConsecutiveFailureThreshold { get; init; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int FailbackProbeIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of consecutive successful probes required before the monitor
|
||||||
|
/// considers the alarm manager recovered and switches back. Must be at
|
||||||
|
/// least 1. Default 3.
|
||||||
|
/// </summary>
|
||||||
|
public int FailbackStableProbes { get; init; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls how the monitor discovers the set of objects to poll when
|
||||||
|
/// operating in subtag-polling fallback mode.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmDiscoveryOptions Discovery { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the subtag names the monitor reads when polling alarm state
|
||||||
|
/// in subtag-fallback mode.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmSubtagNameOptions Subtags { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Governs how the alarm monitor discovers objects to include in subtag-polling
|
||||||
|
/// fallback mode. Either the Galaxy Repository query (when
|
||||||
|
/// <see cref="UseGalaxyRepository"/> is <c>true</c>) or an explicit
|
||||||
|
/// <see cref="IncludeAttributes"/> list must be supplied when
|
||||||
|
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmDiscoveryOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c> the monitor queries the Galaxy Repository SQL database
|
||||||
|
/// to enumerate alarm objects for the configured area. Default <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseGalaxyRepository { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Galaxy area to scope the Repository query to. When empty the monitor
|
||||||
|
/// falls back to <see cref="AlarmsOptions.DefaultArea"/>. Ignored when
|
||||||
|
/// <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Area { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explicit list of MXAccess attribute paths to include in subtag polling,
|
||||||
|
/// supplementing (or replacing, when <see cref="UseGalaxyRepository"/> is
|
||||||
|
/// <c>false</c>) the Repository-derived list. Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string[] IncludeAttributes { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attribute paths to exclude from the Repository-derived poll list.
|
||||||
|
/// Ignored when <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||||
|
/// Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string[] ExcludeAttributes { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmSubtagNameOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the active-alarm flag. Default <c>active</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Active { get; init; } = "active";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the acknowledged flag. Default <c>acked</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Acked { get; init; } = "acked";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional subtag name for the acknowledgement comment field.
|
||||||
|
/// When empty the feature is disabled. Verified against MXAccess at
|
||||||
|
/// runtime before use. Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string AckComment { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the alarm priority. Default <c>priority</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Priority { get; init; } = "priority";
|
||||||
|
}
|
||||||
@@ -45,4 +45,12 @@ public sealed class AlarmsOptions
|
|||||||
/// the monitor floors it at 5 seconds.
|
/// the monitor floors it at 5 seconds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReconcileIntervalSeconds { get; init; } = 30;
|
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmFallbackOptions Fallback { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
builder);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
@@ -255,6 +257,46 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
builder.Add(
|
builder.Add(
|
||||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> 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;
|
private const int MinimumCertValidityYears = 1;
|
||||||
|
|||||||
@@ -118,4 +118,181 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
Assert.True(result.Succeeded);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user