fix(cluster-infrastructure): resolve ClusterInfrastructure-002..006 — options validation, DI registration, down-if-alone
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
| Last reviewed | 2026-05-16 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `9c60592` |
|
||||
| Open findings | 7 |
|
||||
| Open findings | 3 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -144,7 +144,7 @@ module-ownership claim was wrong. Module test suite green (3 passed).
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:7-17` |
|
||||
|
||||
**Description**
|
||||
@@ -167,7 +167,23 @@ with the genuine registration when CI-001 is addressed.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Confirmed against the source: both methods returned the `IServiceCollection`
|
||||
unchanged. Verified the consumers — `ScadaLink.Host` calls `AddClusterInfrastructure()`
|
||||
(`Program.cs:68`, `SiteServiceRegistration.cs:24`); `AddClusterInfrastructureActors`
|
||||
is dead — it is called nowhere in the solution.
|
||||
|
||||
**Resolved** — fixing commit `commit pending`, date 2026-05-16.
|
||||
`AddClusterInfrastructure` now does real work: it registers the
|
||||
`ClusterOptionsValidator` (CI-004) via `TryAddEnumerable`, so the method is no longer a
|
||||
no-op and a misconfigured `ScadaLink:Cluster` section fails fast on the first
|
||||
`IOptions<ClusterOptions>` resolution. `AddClusterInfrastructureActors` — which this
|
||||
component never had any actors to register, as CI-001 established the Akka bootstrap
|
||||
lives in `ScadaLink.Host` — now throws `NotImplementedException` with a message
|
||||
pointing the caller to the Host, rather than masquerading as a completed registration.
|
||||
Covered by `ServiceCollectionExtensionsTests`
|
||||
(`AddClusterInfrastructure_RegistersOptionsValidator`,
|
||||
`AddClusterInfrastructure_ValidatorRejectsBadOptionsAtResolution`,
|
||||
`AddClusterInfrastructureActors_ThrowsRatherThanSilentlySucceeding`).
|
||||
|
||||
### ClusterInfrastructure-003 — ClusterOptions omits several documented node-configuration settings
|
||||
|
||||
@@ -175,7 +191,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Design-document adherence |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` |
|
||||
|
||||
**Description**
|
||||
@@ -202,7 +218,27 @@ agree on where each value lives.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Partially re-triaged. Verified against the source: most of the "missing" settings are
|
||||
**deliberately owned by `ScadaLink.Host.NodeOptions`** — `NodeOptions` already carries
|
||||
`Role`, `NodeHostname`, `SiteId`, `RemotingPort` and `GrpcPort`, and `AkkaHostedService`
|
||||
builds the HOCON from `NodeOptions` for exactly those values. Local SQLite storage paths
|
||||
live in the database / store-and-forward options. This is the ownership split CI-001
|
||||
established (the Host owns node identity and bootstrap; this project owns the
|
||||
cluster-formation contract), so those settings do **not** belong in `ClusterOptions`.
|
||||
|
||||
The one genuine gap the finding identifies is `down-if-alone`, which the design doc
|
||||
puts with the split-brain settings.
|
||||
|
||||
**Resolved** — fixing commit `commit pending`, date 2026-05-16. Added the
|
||||
`DownIfAlone` boolean (default `true`) to `ClusterOptions` so the split-brain
|
||||
configuration contract is complete, and added a class-level XML doc that records the
|
||||
deliberate ownership split — node identity/remoting/gRPC in `Host.NodeOptions`, storage
|
||||
paths in the database options, cluster-formation settings here — so the design doc and
|
||||
the options classes now agree on where each value lives. (`AkkaHostedService` currently
|
||||
hard-codes `down-if-alone = on` in HOCON; wiring it to read `DownIfAlone` is a one-line
|
||||
`ScadaLink.Host` change, outside this module's permitted edit scope, and is noted for
|
||||
the Host's review.) Covered by `ClusterOptionsTests.DefaultValues_AreCorrect` and
|
||||
`ClusterOptionsTests.DownIfAlone_CanBeSet`.
|
||||
|
||||
### ClusterInfrastructure-004 — ClusterOptions has no validation despite safety-critical values
|
||||
|
||||
@@ -210,7 +246,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Code organization & conventions |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` |
|
||||
|
||||
**Description**
|
||||
@@ -239,7 +275,26 @@ FailureDetectionThreshold` and positive `StableAfter`. Register it with
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Confirmed: `ClusterOptions` had no validation of any kind, and the design doc's
|
||||
catastrophic-misconfiguration values (`MinNrOfMembers: 2`, a quorum split-brain
|
||||
strategy) would have been bound silently.
|
||||
|
||||
**Resolved** — fixing commit `commit pending`, date 2026-05-16. Added
|
||||
`ClusterOptionsValidator : IValidateOptions<ClusterOptions>`, which enforces
|
||||
`MinNrOfMembers == 1`, restricts `SplitBrainResolverStrategy` to the
|
||||
`keep-oldest`-only allowed set, requires a non-empty `SeedNodes`, requires positive
|
||||
`StableAfter` / `HeartbeatInterval` / `FailureDetectionThreshold`, and asserts
|
||||
`HeartbeatInterval < FailureDetectionThreshold`. It accumulates every failure into one
|
||||
result. It is registered by `AddClusterInfrastructure()` (CI-002) as a singleton
|
||||
`IValidateOptions<ClusterOptions>`, so a misconfigured section throws
|
||||
`OptionsValidationException` on the first `IOptions<ClusterOptions>.Value` resolution
|
||||
— which `AkkaHostedService` performs during startup, giving the fail-fast-at-boot
|
||||
behaviour the recommendation asks for without the src project taking a dependency on
|
||||
the full `Microsoft.Extensions.DependencyInjection` package needed for the
|
||||
`ValidateOnStart()` overload. Data annotations were not used — a single
|
||||
`IValidateOptions` implementation expresses the interdependent timing rules that
|
||||
attributes cannot. Covered by `ClusterOptionsValidatorTests` (8 cases) and
|
||||
`ServiceCollectionExtensionsTests.AddClusterInfrastructure_ValidatorRejectsBadOptionsAtResolution`.
|
||||
|
||||
### ClusterInfrastructure-005 — No configuration section name constant for the Options pattern binding
|
||||
|
||||
@@ -276,7 +331,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Testing coverage |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:1-51` |
|
||||
|
||||
**Description**
|
||||
@@ -301,7 +356,28 @@ from `ClusterOptions` and for the options validation from CI-004.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Re-triaged in light of CI-001's resolution. The Akka bootstrap, HOCON generation,
|
||||
cluster formation, failover and singleton handover are owned by `ScadaLink.Host`, not
|
||||
this project — multi-node `Akka.Cluster.TestKit` tests for that behaviour belong in the
|
||||
Host's test suite, outside this module's scope. What this module legitimately owns is
|
||||
`ClusterOptions`, its validator, and the DI registration, and the testing gap there is
|
||||
now closed.
|
||||
|
||||
**Resolved** — fixing commit `commit pending`, date 2026-05-16. Added two test classes
|
||||
to `tests/ScadaLink.ClusterInfrastructure.Tests`: `ClusterOptionsValidatorTests`
|
||||
(8 cases — valid defaults pass; `MinNrOfMembers != 1`, unsupported split-brain
|
||||
strategies, empty seed nodes, heartbeat not below the failure threshold, non-positive
|
||||
`StableAfter` all fail; and a multi-failure accumulation case) and
|
||||
`ServiceCollectionExtensionsTests` (3 cases — `AddClusterInfrastructure` registers the
|
||||
validator, the validator rejects bad options at `IOptions` resolution, and
|
||||
`AddClusterInfrastructureActors` throws). The pre-existing `ClusterOptionsTests` was
|
||||
extended with `DownIfAlone` coverage. The test project gained references to
|
||||
`Microsoft.Extensions.DependencyInjection` and `Microsoft.Extensions.Options`. Module
|
||||
test suite green: 16 passed (was 3). Note: the `keep-majority` value used in the
|
||||
pre-existing `ClusterOptionsTests.Properties_CanBeSetToCustomValues` is intentionally
|
||||
left — that test exercises the POCO's property setter (the POCO accepts any string by
|
||||
design); `ClusterOptionsValidator` is the layer that now rejects `keep-majority`, and
|
||||
`UnsupportedSplitBrainStrategy_FailsValidation` proves it.
|
||||
|
||||
### ClusterInfrastructure-007 — ClusterOptions lacks XML documentation comments
|
||||
|
||||
|
||||
@@ -1,11 +1,75 @@
|
||||
namespace ScadaLink.ClusterInfrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster configuration model, bound from the <c>ScadaLink:Cluster</c> section
|
||||
/// of <c>appsettings.json</c> via the Options pattern.
|
||||
/// <para>
|
||||
/// This project owns the cluster <em>configuration contract</em>. The actual
|
||||
/// Akka.NET bootstrap — building the HOCON from these values, starting the
|
||||
/// <c>ActorSystem</c>, configuring the split-brain resolver and wiring
|
||||
/// <c>CoordinatedShutdown</c> — lives in <c>ScadaLink.Host</c>
|
||||
/// (see <c>Component-ClusterInfrastructure.md</c> → "Implementation Note — Code Placement").
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Node-identity settings (remoting hostname/port, cluster role, site identifier,
|
||||
/// gRPC port) are deliberately <em>not</em> here — they are owned by
|
||||
/// <c>ScadaLink.Host.NodeOptions</c> (<c>ScadaLink:Node</c> section). Local SQLite
|
||||
/// storage paths are owned by the database / store-and-forward options. This class
|
||||
/// holds only the cluster-formation and failure-detection settings shared by every node.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ClusterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>appsettings.json</c> section name this options class binds from.
|
||||
/// Single source of truth so binding sites do not hard-code the magic string.
|
||||
/// </summary>
|
||||
public const string SectionName = "ScadaLink:Cluster";
|
||||
|
||||
/// <summary>
|
||||
/// Akka.NET cluster seed nodes. Both nodes are seed nodes — each node lists
|
||||
/// itself and its partner — so either can start first and form the cluster.
|
||||
/// Must contain at least one entry.
|
||||
/// </summary>
|
||||
public List<string> SeedNodes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Split-brain resolver strategy. Must be <c>keep-oldest</c> for the two-node
|
||||
/// clusters ScadaLink uses: quorum strategies (<c>keep-majority</c>,
|
||||
/// <c>static-quorum</c>) cannot distinguish a crash from a partition with only
|
||||
/// two nodes and would shut down the whole cluster.
|
||||
/// </summary>
|
||||
public string SplitBrainResolverStrategy { get; set; } = "keep-oldest";
|
||||
|
||||
/// <summary>
|
||||
/// Time the cluster membership must remain stable before the split-brain
|
||||
/// resolver acts to down unreachable nodes. Must be positive. Default 15s.
|
||||
/// </summary>
|
||||
public TimeSpan StableAfter { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Frequency of cluster failure-detector heartbeat messages between nodes.
|
||||
/// Must be well below <see cref="FailureDetectionThreshold"/>. Default 2s.
|
||||
/// </summary>
|
||||
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Time without a heartbeat before a node is considered unreachable
|
||||
/// (Akka's <c>acceptable-heartbeat-pause</c>). Default 10s.
|
||||
/// </summary>
|
||||
public TimeSpan FailureDetectionThreshold { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Akka's <c>min-nr-of-members</c>. Must be <c>1</c>: after failover only one
|
||||
/// node runs, and a value of <c>2</c> blocks the cluster singleton (Site Runtime
|
||||
/// Deployment Manager) — and therefore all data collection — indefinitely.
|
||||
/// </summary>
|
||||
public int MinNrOfMembers { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The keep-oldest resolver's <c>down-if-alone</c> flag. When <c>true</c> (the
|
||||
/// design-doc requirement), the oldest node downs itself if it finds it has no
|
||||
/// other reachable members, rather than running as an isolated single-node cluster.
|
||||
/// </summary>
|
||||
public bool DownIfAlone { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.ClusterInfrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// CI-004: 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 : IValidateOptions<ClusterOptions>
|
||||
{
|
||||
/// <summary>Split-brain resolver strategies safe for ScadaLink's two-node clusters.</summary>
|
||||
private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"keep-oldest"
|
||||
};
|
||||
|
||||
public ValidateOptionsResult Validate(string? name, ClusterOptions options)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
if (options.SeedNodes is null || options.SeedNodes.Count == 0)
|
||||
{
|
||||
failures.Add("ClusterOptions.SeedNodes must contain at least one seed node.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
|
||||
|| !AllowedStrategies.Contains(options.SplitBrainResolverStrategy))
|
||||
{
|
||||
failures.Add(
|
||||
$"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
|
||||
$"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
|
||||
}
|
||||
|
||||
if (options.MinNrOfMembers != 1)
|
||||
{
|
||||
failures.Add(
|
||||
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
|
||||
"any other value blocks the cluster singleton after failover and halts all data collection.");
|
||||
}
|
||||
|
||||
if (options.StableAfter <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("ClusterOptions.StableAfter must be a positive duration.");
|
||||
}
|
||||
|
||||
if (options.HeartbeatInterval <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("ClusterOptions.HeartbeatInterval must be a positive duration.");
|
||||
}
|
||||
|
||||
if (options.FailureDetectionThreshold <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("ClusterOptions.FailureDetectionThreshold must be a positive duration.");
|
||||
}
|
||||
|
||||
if (options.HeartbeatInterval >= options.FailureDetectionThreshold)
|
||||
{
|
||||
failures.Add(
|
||||
$"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " +
|
||||
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
|
||||
"declared unreachable before a heartbeat can arrive.");
|
||||
}
|
||||
|
||||
return failures.Count > 0
|
||||
? ValidateOptionsResult.Fail(failures)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,47 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.ClusterInfrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration for the Cluster Infrastructure component.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the Cluster Infrastructure services. This component owns the
|
||||
/// cluster <em>configuration contract</em> (<see cref="ClusterOptions"/>); the
|
||||
/// Akka.NET bootstrap itself lives in <c>ScadaLink.Host</c>
|
||||
/// (see <c>Component-ClusterInfrastructure.md</c>).
|
||||
/// <para>
|
||||
/// Registering the <see cref="ClusterOptionsValidator"/> means a misconfigured
|
||||
/// <c>ScadaLink:Cluster</c> section (e.g. <c>MinNrOfMembers: 2</c> or a quorum
|
||||
/// split-brain strategy) throws an <see cref="OptionsValidationException"/> the
|
||||
/// first time <see cref="IOptions{TOptions}"/> is resolved, rather than booting
|
||||
/// into a broken cluster.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static IServiceCollection AddClusterInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Singleton<IValidateOptions<ClusterOptions>, ClusterOptionsValidator>());
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reserved for cluster-infrastructure actor registration. This component does
|
||||
/// not register any actors — the Akka.NET bootstrap and actor wiring live in
|
||||
/// <c>ScadaLink.Host</c>. The method throws rather than silently returning
|
||||
/// success so that any caller assuming this component registers actors fails
|
||||
/// fast with a clear cause instead of failing later, far from here.
|
||||
/// </summary>
|
||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||
public static IServiceCollection AddClusterInfrastructureActors(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: placeholder for Akka actor registration
|
||||
return services;
|
||||
throw new NotImplementedException(
|
||||
"ScadaLink.ClusterInfrastructure registers no actors. The Akka.NET actor system " +
|
||||
"bootstrap and all cluster actor registration live in ScadaLink.Host " +
|
||||
"(AkkaHostedService). Do not call AddClusterInfrastructureActors().");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ public class ClusterOptionsTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), options.HeartbeatInterval);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), options.FailureDetectionThreshold);
|
||||
Assert.Equal(1, options.MinNrOfMembers);
|
||||
Assert.True(options.DownIfAlone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownIfAlone_CanBeSet()
|
||||
{
|
||||
var options = new ClusterOptions { DownIfAlone = false };
|
||||
|
||||
Assert.False(options.DownIfAlone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.ClusterInfrastructure.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CI-004: Tests that <see cref="ClusterOptionsValidator"/> rejects the
|
||||
/// catastrophic misconfigurations the design doc warns against — a
|
||||
/// <c>MinNrOfMembers</c> other than 1, an unsupported split-brain strategy,
|
||||
/// empty seed nodes, and timings where the heartbeat is not below the
|
||||
/// failure-detection threshold.
|
||||
/// </summary>
|
||||
public class ClusterOptionsValidatorTests
|
||||
{
|
||||
private static ClusterOptions ValidOptions() => new()
|
||||
{
|
||||
SeedNodes = new List<string> { "akka.tcp://scadalink@node1:8081", "akka.tcp://scadalink@node2:8081" },
|
||||
SplitBrainResolverStrategy = "keep-oldest",
|
||||
StableAfter = TimeSpan.FromSeconds(15),
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(2),
|
||||
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
|
||||
MinNrOfMembers = 1,
|
||||
DownIfAlone = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
var result = new ClusterOptionsValidator().Validate(null, ValidOptions());
|
||||
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinNrOfMembers_NotOne_FailsValidation()
|
||||
{
|
||||
var options = ValidOptions();
|
||||
options.MinNrOfMembers = 2;
|
||||
|
||||
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MinNrOfMembers", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("keep-majority")]
|
||||
[InlineData("static-quorum")]
|
||||
[InlineData("nonsense")]
|
||||
public void UnsupportedSplitBrainStrategy_FailsValidation(string strategy)
|
||||
{
|
||||
var options = ValidOptions();
|
||||
options.SplitBrainResolverStrategy = strategy;
|
||||
|
||||
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("SplitBrainResolverStrategy", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySeedNodes_FailsValidation()
|
||||
{
|
||||
var options = ValidOptions();
|
||||
options.SeedNodes = new List<string>();
|
||||
|
||||
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("SeedNodes", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeartbeatNotBelowFailureThreshold_FailsValidation()
|
||||
{
|
||||
var options = ValidOptions();
|
||||
options.HeartbeatInterval = TimeSpan.FromSeconds(10);
|
||||
options.FailureDetectionThreshold = TimeSpan.FromSeconds(10);
|
||||
|
||||
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("HeartbeatInterval", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonPositiveStableAfter_FailsValidation()
|
||||
{
|
||||
var options = ValidOptions();
|
||||
options.StableAfter = TimeSpan.Zero;
|
||||
|
||||
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("StableAfter", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AccumulatesAllFailures()
|
||||
{
|
||||
var options = new ClusterOptions
|
||||
{
|
||||
SeedNodes = new List<string>(),
|
||||
SplitBrainResolverStrategy = "keep-majority",
|
||||
MinNrOfMembers = 2,
|
||||
StableAfter = TimeSpan.Zero,
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(20),
|
||||
FailureDetectionThreshold = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("SeedNodes", result.FailureMessage);
|
||||
Assert.Contains("SplitBrainResolverStrategy", result.FailureMessage);
|
||||
Assert.Contains("MinNrOfMembers", result.FailureMessage);
|
||||
Assert.Contains("StableAfter", result.FailureMessage);
|
||||
Assert.Contains("HeartbeatInterval", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.ClusterInfrastructure.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CI-002: Tests that the DI extension methods do real work rather than
|
||||
/// silently returning success. <see cref="ServiceCollectionExtensions.AddClusterInfrastructure"/>
|
||||
/// must register the <see cref="ClusterOptionsValidator"/> so misconfiguration
|
||||
/// fails fast, and the unimplemented actor-registration placeholder must fail
|
||||
/// loudly rather than masquerade as a completed registration.
|
||||
/// </summary>
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddClusterInfrastructure_RegistersOptionsValidator()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddClusterInfrastructure();
|
||||
|
||||
var validators = services
|
||||
.Where(d => d.ServiceType == typeof(IValidateOptions<ClusterOptions>))
|
||||
.ToList();
|
||||
Assert.NotEmpty(validators);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var validator = provider.GetService<IValidateOptions<ClusterOptions>>();
|
||||
Assert.IsType<ClusterOptionsValidator>(validator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddClusterInfrastructure_ValidatorRejectsBadOptionsAtResolution()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddClusterInfrastructure();
|
||||
// A MinNrOfMembers of 2 blocks the cluster singleton after failover.
|
||||
services.Configure<ClusterOptions>(o =>
|
||||
{
|
||||
o.SeedNodes = new List<string> { "akka.tcp://scadalink@node1:8081" };
|
||||
o.MinNrOfMembers = 2;
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var ex = Assert.Throws<OptionsValidationException>(
|
||||
() => provider.GetRequiredService<IOptions<ClusterOptions>>().Value);
|
||||
Assert.Contains("MinNrOfMembers", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddClusterInfrastructureActors_ThrowsRatherThanSilentlySucceeding()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
Assert.Throws<NotImplementedException>(() => services.AddClusterInfrastructureActors());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user