fix(cluster): AkkaClusterOptions.Roles is the EffectiveRoles instance, not a second derivation

This commit is contained in:
Joseph Doherty
2026-08-20 09:43:54 -04:00
parent 7702616716
commit 7dfd6dcc9f
3 changed files with 55 additions and 14 deletions
@@ -6,6 +6,7 @@ using Akka.Logger.Serilog;
using Akka.Remote.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
@@ -34,21 +35,31 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
configuration, AkkaClusterOptions.SectionName);
// Gitea #532: apply the OTOPCUA_ROLES fallback the Roles property has always DOCUMENTED, in the
// one place every consumer reads through. Before this, a node configured only through the env
// var — the knob that drives all of Program.cs's role wiring — bound an EMPTY Roles array and so
// joined the cluster role-less: invisible to RoleLeader/MembersWithRole, ineligible for every
// role-scoped singleton, and un-deployable ("no driver-role nodes are Up") with nothing in its
// own logs to say so. Doing it here rather than at each call site means BuildClusterOptions
// (Akka's own member roles), ClusterRoleInfo, the ControlPlane singleton scope and
// ConfigDbApiKeyStore's ClusterId all see the same answer.
// R3-04 (structural identity, choice 3): EffectiveRoles is resolved ONCE and registered as a
// singleton, off the SAME IConfiguration this call received — not off a DI-resolved
// IConfiguration, which a plain ServiceCollection (as opposed to a WebApplicationBuilder) never
// registers. Every consumer (Program.cs's hasAdmin/hasDriver, the four role-scoped validators,
// AkkaClusterOptions.Roles below) therefore reads the exact same instance, so there is no second
// derivation left to disagree with — the boot-guard OVERALL asked for is this identity, not a
// runtime assert (see EffectiveRoles + AkkaClusterOptionsValidator's post-overlay guard).
// TryAddSingleton keeps this usable from a harness that registers its own EffectiveRoles first.
services.TryAddSingleton(_ => EffectiveRoles.Resolve(configuration));
// Gitea #532 (superseded by R3-04): apply the OTOPCUA_ROLES fallback the Roles property has
// always DOCUMENTED, in the one place every consumer reads through. Before this, a node
// configured only through the env var — the knob that drives all of Program.cs's role wiring —
// bound an EMPTY Roles array and so joined the cluster role-less: invisible to
// RoleLeader/MembersWithRole, ineligible for every role-scoped singleton, and un-deployable
// ("no driver-role nodes are Up") with nothing in its own logs to say so. Doing it here rather
// than at each call site means BuildClusterOptions (Akka's own member roles), ClusterRoleInfo,
// the ControlPlane singleton scope and ConfigDbApiKeyStore's ClusterId all see the same answer.
//
// PostConfigure, not a bind trick: it runs after the section binds and before ValidateOnStart,
// so the validators below see the effective list. An unknown role in the env var throws out of
// RoleParser.Parse — the same throw Program.cs already takes on the same value, so an invalid
// env var cannot reach here on the production path in the first place.
services.PostConfigure<AkkaClusterOptions>(
options => options.Roles = RoleParser.ResolveEffectiveRoles(options.Roles));
// so the validators below see the effective list. Now consumes the registered EffectiveRoles
// instance rather than re-deriving from options.Roles + the environment — options.Roles becomes
// BY CONSTRUCTION the same list Program.cs's hasAdmin/hasDriver are computed from.
services.AddOptions<AkkaClusterOptions>().PostConfigure<EffectiveRoles>(
(options, roles) => options.Roles = [.. roles.Gossiped]);
// Per-cluster mesh Phase 2: which transport carries central→node commands. Validated at
// startup for the same reason the cluster options are — a contact point that cannot resolve