Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs
T
Joseph Doherty ce66e194a8 feat(cluster): AllowSingleNodeCluster flag — single-seed installs drop the phantom seed
Validator lowers the seed-count floor to 1 when AllowSingleNodeCluster is set,
otherwise still requires 2 and points operators at the flag. The plan's
deploy/wonder-app-vd03/appsettings.Central.json edit is skipped: that production
deploy artifact is not tracked in this repo (only its deployment record doc is).
2026-07-08 16:39:12 -04:00

68 lines
3.8 KiB
C#

using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
/// <summary>
/// Validates <see cref="ClusterOptions"/> at startup. The values it
/// guards carry cluster-wide consequences — the design doc
/// (<c>Component-ClusterInfrastructure.md</c>) is emphatic that misconfiguring
/// them produces a total cluster shutdown or an indefinitely blocked singleton.
/// Registered with <c>ValidateOnStart()</c> so a bad <c>appsettings.json</c>
/// fails fast at boot rather than failing far from the cause.
/// </summary>
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
{
/// <summary>Split-brain resolver strategies safe for ScadaBridge's two-node clusters.</summary>
private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase)
{
"keep-oldest"
};
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, ClusterOptions options)
{
// The 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).
var minSeeds = options.AllowSingleNodeCluster ? 1 : 2;
builder.RequireThat(options.SeedNodes is not null && options.SeedNodes.Count >= minSeeds,
options.AllowSingleNodeCluster
? "ClusterOptions.SeedNodes must contain at least 1 seed node."
: "ClusterOptions.SeedNodes must contain at least 2 seed nodes "
+ "(Component-ClusterInfrastructure.md → Node Configuration: both nodes are seed nodes); "
+ "for a deliberate single-node install set ClusterOptions.AllowSingleNodeCluster = true instead of listing a phantom seed.");
builder.RequireThat(
!string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
&& AllowedStrategies.Contains(options.SplitBrainResolverStrategy),
$"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
$"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
builder.RequireThat(options.MinNrOfMembers == 1,
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
"any other value blocks the cluster singleton after failover and halts all data collection.");
builder.RequireThat(options.StableAfter > TimeSpan.Zero,
"ClusterOptions.StableAfter must be a positive duration.");
builder.RequireThat(options.HeartbeatInterval > TimeSpan.Zero,
"ClusterOptions.HeartbeatInterval must be a positive duration.");
builder.RequireThat(options.FailureDetectionThreshold > TimeSpan.Zero,
"ClusterOptions.FailureDetectionThreshold must be a positive duration.");
builder.RequireThat(options.HeartbeatInterval < options.FailureDetectionThreshold,
$"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " +
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
"declared unreachable before a heartbeat can arrive.");
builder.RequireThat(options.DownIfAlone,
"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.");
}
}