diff --git a/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json b/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json index b203876b..8a84f165 100644 --- a/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json +++ b/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json @@ -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" ] diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs index a8d09b08..a9b944c9 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs @@ -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( 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( - 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().PostConfigure( + (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 diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/AkkaClusterOptionsRoleFallbackTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/AkkaClusterOptionsRoleFallbackTests.cs index 90ecdb3c..a3e2a814 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/AkkaClusterOptionsRoleFallbackTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/AkkaClusterOptionsRoleFallbackTests.cs @@ -94,4 +94,34 @@ public class AkkaClusterOptionsRoleFallbackTests RoleParser.ClusterIdFromRoles(Bind().Roles).ShouldBe("site-a"); } } + + /// + /// R3-04 (structural identity, choice 3): AkkaClusterOptions.Roles is not a SECOND + /// derivation of the role rule — it is the registered instance's + /// 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. + /// + [Fact] + public void Bound_options_Roles_IS_the_registered_EffectiveRoles_Gossiped_list() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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>().Value; + var effectiveRoles = provider.GetRequiredService(); + + bound.Roles.ShouldBe(new[] { "driver", "cluster-SITE-A" }); + bound.Roles.ShouldBe(effectiveRoles.Gossiped); + } }