fix(cluster-infrastructure): resolve ClusterInfrastructure-002..006 — options validation, DI registration, down-if-alone

This commit is contained in:
Joseph Doherty
2026-05-16 20:58:03 -04:00
parent 71b90ba499
commit dba1a1b25f
8 changed files with 441 additions and 12 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-16 | | Last reviewed | 2026-05-16 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `9c60592` | | Commit reviewed | `9c60592` |
| Open findings | 7 | | Open findings | 3 |
## Summary ## Summary
@@ -144,7 +144,7 @@ module-ownership claim was wrong. Module test suite green (3 passed).
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:7-17` | | Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:7-17` |
**Description** **Description**
@@ -167,7 +167,23 @@ with the genuine registration when CI-001 is addressed.
**Resolution** **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 ### ClusterInfrastructure-003 — ClusterOptions omits several documented node-configuration settings
@@ -175,7 +191,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Design-document adherence | | Category | Design-document adherence |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` | | Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` |
**Description** **Description**
@@ -202,7 +218,27 @@ agree on where each value lives.
**Resolution** **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 ### ClusterInfrastructure-004 — ClusterOptions has no validation despite safety-critical values
@@ -210,7 +246,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` | | Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` |
**Description** **Description**
@@ -239,7 +275,26 @@ FailureDetectionThreshold` and positive `StableAfter`. Register it with
**Resolution** **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 ### ClusterInfrastructure-005 — No configuration section name constant for the Options pattern binding
@@ -276,7 +331,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Testing coverage | | Category | Testing coverage |
| Status | Open | | Status | Resolved |
| Location | `tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:1-51` | | Location | `tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:1-51` |
**Description** **Description**
@@ -301,7 +356,28 @@ from `ClusterOptions` and for the options validation from CI-004.
**Resolution** **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 ### ClusterInfrastructure-007 — ClusterOptions lacks XML documentation comments

View File

@@ -1,11 +1,75 @@
namespace ScadaLink.ClusterInfrastructure; 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 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(); 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"; 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); 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); 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); 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; 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;
} }

View File

@@ -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;
}
}

View File

@@ -1,18 +1,47 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace ScadaLink.ClusterInfrastructure; namespace ScadaLink.ClusterInfrastructure;
/// <summary>
/// DI registration for the Cluster Infrastructure component.
/// </summary>
public static class ServiceCollectionExtensions 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) public static IServiceCollection AddClusterInfrastructure(this IServiceCollection services)
{ {
// Phase 0: skeleton only services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<ClusterOptions>, ClusterOptionsValidator>());
return services; 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) public static IServiceCollection AddClusterInfrastructureActors(this IServiceCollection services)
{ {
// Phase 0: placeholder for Akka actor registration throw new NotImplementedException(
return services; "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().");
} }
} }

View File

@@ -15,6 +15,15 @@ public class ClusterOptionsTests
Assert.Equal(TimeSpan.FromSeconds(2), options.HeartbeatInterval); Assert.Equal(TimeSpan.FromSeconds(2), options.HeartbeatInterval);
Assert.Equal(TimeSpan.FromSeconds(10), options.FailureDetectionThreshold); Assert.Equal(TimeSpan.FromSeconds(10), options.FailureDetectionThreshold);
Assert.Equal(1, options.MinNrOfMembers); 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] [Fact]

View File

@@ -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);
}
}

View File

@@ -10,6 +10,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />

View File

@@ -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());
}
}