using Akka.Actor; using Microsoft.Extensions.Configuration; using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.OtOpcUa.Cluster; /// /// Fail-fast startup validator for , built on the shared /// ZB.MOM.WW.Configuration and wired through /// AddValidatedOptions in . /// /// /// /// Self-first seed ordering (decision 2026-07-22). Akka runs /// FirstSeedNodeProcess — the only bootstrap path that can form a NEW cluster when no /// peer answers InitJoin — exclusively when seed-nodes[0] is this node's own /// address. Every other node runs JoinSeedNodeProcess and retries InitJoin /// forever. A node that lists its partner first therefore cannot cold-start while that /// partner is down, and the failure is silent: 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. /// /// /// The rule is conditional and must stay that way. It binds only when this node's own /// address appears in its own seed list. A driver-only site node is seeded solely by /// central-1 and is legitimately not a seed of anything; an unconditional "self must /// be first" rule would refuse to boot every one of them. /// /// /// Identity is the advertised address, never the bind address. In docker-dev /// Cluster:Hostname is 0.0.0.0 and Cluster:PublicHostname is the /// container DNS name — the value Akka puts in SelfAddress and the only one a seed URI /// can match. Comparing against Hostname would match nothing anywhere, silently exempt /// every node, and leave this rule inert. PublicHostname falling back to /// Hostname when blank mirrors Akka's own public-hostname semantics. /// /// public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase { private readonly EffectiveRoles? _effectiveRoles; private readonly IConfiguration? _configuration; /// /// DI-constructed by AddValidatedOptions (a plain AddSingleton), so constructor /// dependencies are safe here. Both and /// are optional (default ) 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. /// /// The registered role authority, or null to skip the post-overlay guard. /// /// The live application configuration — deliberately NOT read through /// 's own snapshot (review finding, 2026-08-20): the guard /// needs to see a role overlay applied AFTER was resolved, /// and only a fresh read off the live can observe that. /// public AkkaClusterOptionsValidator(EffectiveRoles? effectiveRoles = null, IConfiguration? configuration = null) { _effectiveRoles = effectiveRoles; _configuration = configuration; } /// protected override void Validate(ValidationBuilder builder, AkkaClusterOptions options) { ArgumentNullException.ThrowIfNull(options); ValidateBootstrapGuard(builder, options.BootstrapGuard); ValidateAgainstEffectiveRoles(builder, options); var seeds = options.SeedNodes ?? Array.Empty(); 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'."); } /// /// When the bootstrap guard is enabled, its timing knobs must be positive. A zero/negative /// PartnerProbeSeconds 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). /// 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})."); } /// /// Post-overlay guard (choice 4, the residual divergence surface): Program.cs resolves /// BEFORE the per-role appsettings overlay loads (the overlay's /// filename depends on the answer), so if a role overlay — or a ${secret:} expansion — /// ever set Cluster:Roles itself, that value would silently disagree with the /// instance every other consumer holds. /// /// /// /// ⚠️ Review finding (2026-08-20): deliberately NOT .Roles. /// 's PostConfigure /// unconditionally sets options.Roles = [.. effectiveRoles.Gossiped] — the very /// structural-identity fix T2 landed — so .Roles and /// .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 /// An_overlay_that_changes_Cluster_Roles_after_resolution_fails_naming_both_views, /// which failed (no throw) against the options.Roles-comparing version of this method. /// /// /// Instead this re-reads the RAW Cluster:Roles section directly off the live /// , which — being the same mutable object /// Program.cs's builder.Configuration is — reflects every provider added up /// to the point something first resolves IOptions<AkkaClusterOptions>, /// INCLUDING a role overlay applied after was captured. /// Skipped when that raw value is empty — the ordinary shape for every /// OTOPCUA_ROLES-only node, where nothing meaningful lives under the /// Cluster:Roles key either before OR after an overlay, and comparing an empty /// array against a non-empty Gossiped list would be a permanent false positive for /// every env-only deployment (which CLAUDE.md notes is most of production). /// /// /// 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 Cluster section. /// /// 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(); 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(normalized, StringComparer.OrdinalIgnoreCase); var registered = new HashSet(_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."); } /// /// True when addresses this node itself — host AND port. /// /// /// Host comparison is case-insensitive because DNS names are, but is otherwise exact: Akka does /// no DNS canonicalisation either, so central-2 and central-2.example.com 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. /// 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); } }