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
@@ -11,7 +11,7 @@
{
"id": "T2",
"subject": "AddOtOpcUaCluster post-configures AkkaClusterOptions.Roles from the registered EffectiveRoles instance (structural identity, no second derivation)",
"status": "pending",
"status": "completed",
"blockedBy": [
"T1"
]
@@ -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
@@ -94,4 +94,34 @@ public class AkkaClusterOptionsRoleFallbackTests
RoleParser.ClusterIdFromRoles(Bind().Roles).ShouldBe("site-a");
}
}
/// <summary>
/// R3-04 (structural identity, choice 3): <c>AkkaClusterOptions.Roles</c> is not a SECOND
/// derivation of the role rule — it is the registered <see cref="EffectiveRoles"/> instance's
/// <see cref="EffectiveRoles.Gossiped"/> list, verbatim. A mixed-case configured role proves
/// both the normalization (plain lowered, cluster suffix preserved) and the identity: the two
/// views can never disagree because there is only one derivation.
/// </summary>
[Fact]
public void Bound_options_Roles_IS_the_registered_EffectiveRoles_Gossiped_list()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Cluster:Roles:0"] = "Driver",
["Cluster:Roles:1"] = "Cluster-SITE-A",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddOtOpcUaCluster(configuration);
var provider = services.BuildServiceProvider();
var bound = provider.GetRequiredService<IOptions<AkkaClusterOptions>>().Value;
var effectiveRoles = provider.GetRequiredService<EffectiveRoles>();
bound.Roles.ShouldBe(new[] { "driver", "cluster-SITE-A" });
bound.Roles.ShouldBe(effectiveRoles.Gossiped);
}
}