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", "id": "T2",
"subject": "AddOtOpcUaCluster post-configures AkkaClusterOptions.Roles from the registered EffectiveRoles instance (structural identity, no second derivation)", "subject": "AddOtOpcUaCluster post-configures AkkaClusterOptions.Roles from the registered EffectiveRoles instance (structural identity, no second derivation)",
"status": "pending", "status": "completed",
"blockedBy": [ "blockedBy": [
"T1" "T1"
] ]
@@ -6,6 +6,7 @@ using Akka.Logger.Serilog;
using Akka.Remote.Hosting; using Akka.Remote.Hosting;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
@@ -34,21 +35,31 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>( services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
configuration, AkkaClusterOptions.SectionName); configuration, AkkaClusterOptions.SectionName);
// Gitea #532: apply the OTOPCUA_ROLES fallback the Roles property has always DOCUMENTED, in the // R3-04 (structural identity, choice 3): EffectiveRoles is resolved ONCE and registered as a
// one place every consumer reads through. Before this, a node configured only through the env // singleton, off the SAME IConfiguration this call received — not off a DI-resolved
// var — the knob that drives all of Program.cs's role wiring — bound an EMPTY Roles array and so // IConfiguration, which a plain ServiceCollection (as opposed to a WebApplicationBuilder) never
// joined the cluster role-less: invisible to RoleLeader/MembersWithRole, ineligible for every // registers. Every consumer (Program.cs's hasAdmin/hasDriver, the four role-scoped validators,
// role-scoped singleton, and un-deployable ("no driver-role nodes are Up") with nothing in its // AkkaClusterOptions.Roles below) therefore reads the exact same instance, so there is no second
// own logs to say so. Doing it here rather than at each call site means BuildClusterOptions // derivation left to disagree with — the boot-guard OVERALL asked for is this identity, not a
// (Akka's own member roles), ClusterRoleInfo, the ControlPlane singleton scope and // runtime assert (see EffectiveRoles + AkkaClusterOptionsValidator's post-overlay guard).
// ConfigDbApiKeyStore's ClusterId all see the same answer. // 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, // 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 // so the validators below see the effective list. Now consumes the registered EffectiveRoles
// RoleParser.Parse — the same throw Program.cs already takes on the same value, so an invalid // instance rather than re-deriving from options.Roles + the environment — options.Roles becomes
// env var cannot reach here on the production path in the first place. // BY CONSTRUCTION the same list Program.cs's hasAdmin/hasDriver are computed from.
services.PostConfigure<AkkaClusterOptions>( services.AddOptions<AkkaClusterOptions>().PostConfigure<EffectiveRoles>(
options => options.Roles = RoleParser.ResolveEffectiveRoles(options.Roles)); (options, roles) => options.Roles = [.. roles.Gossiped]);
// Per-cluster mesh Phase 2: which transport carries central→node commands. Validated at // 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 // 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"); 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);
}
} }