fix(cluster): fail start when a late config layer changes Cluster:Roles (role overlays may not set it)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user