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