Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaClusterOptionsValidator.cs
T
Joseph Doherty 826312befe docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
2026-10-15 06:14:58 -04:00

223 lines
12 KiB
C#

using Akka.Actor;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Fail-fast startup validator for <see cref="AkkaClusterOptions"/>, built on the shared
/// <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/> and wired through
/// <c>AddValidatedOptions</c> in <see cref="ServiceCollectionExtensions.AddOtOpcUaCluster"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Self-first seed ordering (decision 2026-07-22).</b> Akka runs
/// <c>FirstSeedNodeProcess</c> — the only bootstrap path that can form a NEW cluster when no
/// peer answers <c>InitJoin</c> — exclusively when <c>seed-nodes[0]</c> is this node's own
/// address. Every other node runs <c>JoinSeedNodeProcess</c> and retries <c>InitJoin</c>
/// forever. A node that lists its partner first therefore cannot cold-start while that
/// partner is down, and the failure is <i>silent</i>: the process runs, the port listens, the
/// node simply never becomes a member. Enforced here so it is loud, at boot, on the node
/// that has the problem.
/// </para>
/// <para>
/// <b>The rule is conditional and must stay that way.</b> It binds only when this node's own
/// address appears in its own seed list. A driver-only site node is seeded solely by
/// <c>central-1</c> and is legitimately not a seed of anything; an unconditional "self must
/// be first" rule would refuse to boot every one of them.
/// </para>
/// <para>
/// <b>Identity is the advertised address, never the bind address.</b> In docker-dev
/// <c>Cluster:Hostname</c> is <c>0.0.0.0</c> and <c>Cluster:PublicHostname</c> is the
/// container DNS name — the value Akka puts in <c>SelfAddress</c> and the only one a seed URI
/// can match. Comparing against <c>Hostname</c> would match nothing anywhere, silently exempt
/// every node, and leave this rule inert. <c>PublicHostname</c> falling back to
/// <c>Hostname</c> when blank mirrors Akka's own <c>public-hostname</c> semantics.
/// </para>
/// </remarks>
public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClusterOptions>
{
private readonly EffectiveRoles? _effectiveRoles;
private readonly IConfiguration? _configuration;
/// <summary>
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
/// dependencies are safe here. Both <paramref name="effectiveRoles"/> and
/// <paramref name="configuration"/> are optional (default <see langword="null"/>) so a caller
/// with neither to hand — including every test in this file that pre-dates the post-overlay guard — keeps
/// constructing this validator parameterlessly; the post-overlay guard below (choice 4) is
/// simply skipped when either is null.
/// </summary>
/// <param name="effectiveRoles">The registered role authority, or null to skip the post-overlay guard.</param>
/// <param name="configuration">
/// The live application configuration — deliberately NOT read through
/// <paramref name="effectiveRoles"/>'s own snapshot (review finding, 2026-08-20): the guard
/// needs to see a role overlay applied AFTER <paramref name="effectiveRoles"/> was resolved,
/// and only a fresh read off the live <see cref="IConfiguration"/> can observe that.
/// </param>
public AkkaClusterOptionsValidator(EffectiveRoles? effectiveRoles = null, IConfiguration? configuration = null)
{
_effectiveRoles = effectiveRoles;
_configuration = configuration;
}
/// <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)
{
// Single-node / dev default: nothing is claimed about ordering, so there is nothing to
// enforce. (Akka bootstraps from an empty seed list by waiting for an explicit join.)
return;
}
var selfHost = string.IsNullOrWhiteSpace(options.PublicHostname)
? options.Hostname
: options.PublicHostname;
var selfIndex = Array.FindIndex(seeds, s => IsSelf(s, selfHost, options.Port));
if (selfIndex <= 0)
{
// -1 = this node is not one of its own seeds (exempt); 0 = correctly ordered.
return;
}
builder.Add(
$"Cluster:SeedNodes must list this node itself first: entry {selfIndex} is this node "
+ $"('{seeds[selfIndex]}') but entry 0 is '{seeds[0]}'. Akka only lets seed-nodes[0] form "
+ "a new cluster (FirstSeedNodeProcess); every other node retries InitJoin forever, so "
+ "with the partner listed first this node can never start while its peer is down. Swap "
+ "the entries — see docs/Redundancy.md → 'Bootstrap: self-first seed ordering'.");
}
/// <summary>
/// When the bootstrap guard is enabled, its timing knobs must be positive. A zero/negative
/// <c>PartnerProbeSeconds</c> in particular silently degrades the guard to "never wait, always
/// conclude the partner is dead" — it would form alone immediately and re-open the very split it
/// exists to close. Fail-fast at boot rather than producing silence (the same rationale every
/// sibling options validator in this project cites).
/// </summary>
private static void ValidateBootstrapGuard(ValidationBuilder builder, ClusterBootstrapGuardOptions guard)
{
if (guard is null || !guard.Enabled) return;
if (guard.PartnerProbeSeconds <= 0)
builder.Add($"Cluster:BootstrapGuard:PartnerProbeSeconds must be > 0 when the guard is enabled (was {guard.PartnerProbeSeconds}).");
if (guard.PartnerProbeIntervalMs <= 0)
builder.Add($"Cluster:BootstrapGuard:PartnerProbeIntervalMs must be > 0 when the guard is enabled (was {guard.PartnerProbeIntervalMs}).");
if (guard.ProbeConnectTimeoutMs <= 0)
builder.Add($"Cluster:BootstrapGuard:ProbeConnectTimeoutMs must be > 0 when the guard is enabled (was {guard.ProbeConnectTimeoutMs}).");
}
/// <summary>
/// Post-overlay guard (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, that value would silently disagree with the
/// <see cref="EffectiveRoles"/> instance every other consumer holds.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>Review finding (2026-08-20): deliberately NOT <paramref name="options"/>.Roles.</b>
/// <see cref="ServiceCollectionExtensions.AddOtOpcUaCluster"/>'s <c>PostConfigure</c>
/// unconditionally sets <c>options.Roles = [.. effectiveRoles.Gossiped]</c> — the very
/// structural-identity fix T2 landed — so <paramref name="options"/>.Roles and
/// <see cref="_effectiveRoles"/>.Gossiped are EQUAL BY CONSTRUCTION on every real host.
/// Comparing them here would make this guard structurally unreachable: it could never
/// observe a divergence no matter what an overlay did, because the same PostConfigure step
/// that a later overlay's Cluster:Roles bind feeds into is exactly what overwrites the
/// difference away before this validator ever runs. Caught by
/// <c>An_overlay_that_changes_Cluster_Roles_after_resolution_fails_naming_both_views</c>,
/// which failed (no throw) against the options.Roles-comparing version of this method.
/// </para>
/// <para>
/// Instead this re-reads the RAW <c>Cluster:Roles</c> section directly off the live
/// <see cref="_configuration"/>, which — being the same mutable object
/// <c>Program.cs</c>'s <c>builder.Configuration</c> is — reflects every provider added up
/// to the point something first resolves <c>IOptions&lt;AkkaClusterOptions&gt;</c>,
/// INCLUDING a role overlay applied after <see cref="_effectiveRoles"/> was captured.
/// Skipped when that raw value is empty — the ordinary shape for every
/// <c>OTOPCUA_ROLES</c>-only node, where nothing meaningful lives under the
/// <c>Cluster:Roles</c> key either before OR after an overlay, and comparing an empty
/// array against a non-empty <c>Gossiped</c> list would be a permanent false positive for
/// every env-only deployment (which CLAUDE.md notes is most of production).
/// </para>
/// <para>
/// 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 for the "nothing changed" case: none of
/// the four shipped overlays carries a <c>Cluster</c> section.
/// </para>
/// </remarks>
private void ValidateAgainstEffectiveRoles(ValidationBuilder builder, AkkaClusterOptions options)
{
_ = options; // intentionally unused — see remarks: comparing against it would be vacuous.
if (_effectiveRoles is null || _configuration is null) return;
var postOverlayRaw = _configuration.GetSection(EffectiveRoles.ConfiguredRolesKey).Get<string[]>();
if (postOverlayRaw is not { Length: > 0 })
{
// Cluster:Roles is empty post-overlay too -> nothing to compare. Correct for every
// OTOPCUA_ROLES-only node, not merely "no data yet".
return;
}
string[] normalized;
try
{
// Reuses EffectiveRoles' own configured-path normalization/validation rather than
// re-implementing it (archreview theme 4 — one rule, stated once).
normalized = [.. EffectiveRoles.Resolve(postOverlayRaw, envRoles: null).Gossiped];
}
catch (ArgumentException ex)
{
builder.Add(
"Cluster:Roles was changed by a configuration layer applied after role resolution to "
+ $"an invalid value ({ex.Message}). A per-role appsettings overlay may not set Cluster:Roles.");
return;
}
var bound = new HashSet<string>(normalized, 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(",", normalized)}]). A per-role appsettings "
+ "overlay may not set Cluster:Roles.");
}
/// <summary>
/// True when <paramref name="seed"/> addresses this node itself — host AND port.
/// </summary>
/// <remarks>
/// Host comparison is case-insensitive because DNS names are, but is otherwise exact: Akka does
/// no DNS canonicalisation either, so <c>central-2</c> and <c>central-2.example.com</c> are
/// genuinely different seed identities to the cluster. A seed entry Akka itself cannot parse is
/// treated as "not this node" — reporting it is Akka's job, with Akka's wording, and this rule
/// must not turn a parse problem into a misleading ordering complaint.
/// </remarks>
private static bool IsSelf(string seed, string selfHost, int selfPort)
{
Address address;
try
{
address = Address.Parse(seed);
}
catch (Exception)
{
return false;
}
return address.Port == selfPort
&& string.Equals(address.Host, selfHost, StringComparison.OrdinalIgnoreCase);
}
}