fix(cluster): fail start when a late config layer changes Cluster:Roles (role overlays may not set it)

This commit is contained in:
Joseph Doherty
2026-08-20 10:11:25 -04:00
parent 3314a1f98d
commit 4380e2e78a
3 changed files with 92 additions and 1 deletions
@@ -51,7 +51,7 @@
{
"id": "T7",
"subject": "AkkaClusterOptionsValidator fails start when a late config layer changes Cluster:Roles (role overlays may not set it)",
"status": "pending",
"status": "completed",
"blockedBy": [
"T2"
]
@@ -36,12 +36,28 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// </remarks>
public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClusterOptions>
{
private readonly EffectiveRoles? _effectiveRoles;
/// <summary>
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
/// constructor dependency is safe here. <paramref name="effectiveRoles"/> is optional (default
/// <see langword="null"/>) so a caller with no role-authority instance to hand — including
/// every test in this file that pre-dates R3-04 — keeps constructing this validator
/// parameterlessly; the post-overlay guard below (choice 4) is simply skipped when it is null.
/// </summary>
/// <param name="effectiveRoles">The registered role authority, or null to skip the post-overlay guard.</param>
public AkkaClusterOptionsValidator(EffectiveRoles? effectiveRoles = null)
{
_effectiveRoles = effectiveRoles;
}
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AkkaClusterOptions options)
{
ArgumentNullException.ThrowIfNull(options);
ValidateBootstrapGuard(builder, options.BootstrapGuard);
ValidateAgainstEffectiveRoles(builder, options);
var seeds = options.SeedNodes ?? Array.Empty<string>();
if (seeds.Length == 0)
@@ -89,6 +105,32 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
builder.Add($"Cluster:BootstrapGuard:ProbeConnectTimeoutMs must be > 0 when the guard is enabled (was {guard.ProbeConnectTimeoutMs}).");
}
/// <summary>
/// R3-04 (choice 4, the residual divergence surface): <c>Program.cs</c> resolves
/// <see cref="EffectiveRoles"/> BEFORE the per-role appsettings overlay loads (the overlay's
/// filename depends on the answer), so if a role overlay — or a <c>${secret:}</c> expansion —
/// ever set <c>Cluster:Roles</c> itself, the bound <paramref name="options"/>.Roles (which
/// sees the FULL, post-overlay configuration) would silently disagree with the
/// <see cref="EffectiveRoles"/> instance every other consumer holds. Compares as SETS
/// (case-insensitively) rather than sequence, because ordering carries no meaning here and a
/// re-ordering is not the hazard this rule exists to catch — a genuinely different role set
/// is. A no-op today: none of the four shipped overlays carries a <c>Cluster</c> section.
/// </summary>
private void ValidateAgainstEffectiveRoles(ValidationBuilder builder, AkkaClusterOptions options)
{
if (_effectiveRoles is null) return;
var bound = new HashSet<string>(options.Roles ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
var registered = new HashSet<string>(_effectiveRoles.Gossiped, StringComparer.OrdinalIgnoreCase);
if (bound.SetEquals(registered)) return;
builder.Add(
"Cluster:Roles was changed by a configuration layer applied after role resolution "
+ $"(host view: [{string.Join(",", _effectiveRoles.Gossiped)}], bound: "
+ $"[{string.Join(",", options.Roles ?? Array.Empty<string>())}]). A per-role appsettings "
+ "overlay may not set Cluster:Roles.");
}
/// <summary>
/// True when <paramref name="seed"/> addresses this node itself — host AND port.
/// </summary>
@@ -214,4 +214,53 @@ public sealed class AkkaClusterOptionsValidatorTests
result.Failed.ShouldBeFalse(result.FailureMessage);
}
// ---- R3-04 choice 4: the post-overlay config-layer guard --------------------------------------
// A role overlay (or a ${secret:} expansion) may not itself set Cluster:Roles — Program.cs
// resolves EffectiveRoles BEFORE the overlay loads (it needs the answer to pick the overlay's
// filename), so if a later config layer changed Cluster:Roles the bound options would disagree
// with every consumer holding the registered EffectiveRoles instance.
[Fact]
public void A_config_layer_that_changes_Cluster_Roles_after_resolution_fails_naming_both_views()
{
var effectiveRoles = EffectiveRoles.Resolve(["driver"], null);
var options = CentralNode("site-a-1", Seed("site-a-1"));
options.Roles = new[] { "admin", "driver" }; // the post-overlay bound value disagrees
var result = new AkkaClusterOptionsValidator(effectiveRoles).Validate(null, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("Cluster:Roles");
result.FailureMessage.ShouldContain("driver");
result.FailureMessage.ShouldContain("admin");
}
[Fact]
public void Bound_Roles_matching_the_registered_EffectiveRoles_passes()
{
var effectiveRoles = EffectiveRoles.Resolve(["driver", "cluster-SITE-A"], null);
var options = CentralNode("site-a-1", Seed("site-a-1"));
options.Roles = new[] { "driver", "cluster-SITE-A" };
var result = new AkkaClusterOptionsValidator(effectiveRoles).Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// A test that never injects <see cref="EffectiveRoles"/> — the pre-T7 construction shape —
/// must keep passing. Without this, giving the ctor a required parameter would silently break
/// every other test in this file rather than a chosen few.
/// </summary>
[Fact]
public void No_EffectiveRoles_injected_skips_the_guard_entirely()
{
var options = CentralNode("site-a-1", Seed("site-a-1"));
options.Roles = new[] { "whatever-this-does-not-validate-roles-either" };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
}