diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs index 22feed72..5ac97d95 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs @@ -20,6 +20,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable private readonly ILogger _logger; private readonly CommonsNodeId _localNode; private readonly HashSet _localRoles; + private readonly string? _clusterRole; + private readonly string? _clusterId; private readonly object _lock = new(); private readonly Dictionary _roleLeaders = new(StringComparer.Ordinal); private readonly Dictionary> _membersByRole = new(StringComparer.Ordinal); @@ -39,6 +41,23 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable _localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}"); _localRoles = new HashSet(options.Value.Roles, StringComparer.Ordinal); + // Derived from the node's OWN configured roles (not live Cluster.State) so this identity is + // available at host-build time, before the cluster forms. Iterate the ORIGINAL array — not + // _localRoles — because first-wins requires configuration order, which HashSet does not + // guarantee. + var clusterRoles = options.Value.Roles.Where(RoleParser.IsClusterRole).ToArray(); + if (clusterRoles.Length > 0) + { + _clusterRole = clusterRoles[0]; + _clusterId = RoleParser.ClusterIdFromRole(_clusterRole); + if (clusterRoles.Length > 1) + { + _logger.LogWarning( + "Node configured with {Count} cluster-scoped roles ({Roles}); using the first ({ClusterRole})", + clusterRoles.Length, string.Join(", ", clusterRoles), _clusterRole); + } + } + SeedFromCurrentState(); _subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber"); } @@ -49,6 +68,12 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable /// public IReadOnlySet LocalRoles => _localRoles; + /// + public string? ClusterRole => _clusterRole; + + /// + public string? ClusterId => _clusterId; + /// public bool HasRole(string role) => _localRoles.Contains(role); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs index 927c0754..bbf82630 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs @@ -14,6 +14,21 @@ public interface IClusterRoleInfo NodeId LocalNode { get; } /// Gets the set of roles assigned to the local node. IReadOnlySet LocalRoles { get; } + + /// + /// Gets the local node's cluster-scoped role (e.g. cluster-SITE-A), or null when + /// the node carries none. Derived from the node's OWN configured roles at construction time — + /// NOT from live cluster membership — so it is available before the cluster forms (per-cluster + /// mesh, Phase 6). When more than one cluster-scoped role is configured, the first one wins. + /// + string? ClusterRole { get; } + + /// + /// Gets the ClusterId extracted from (e.g. SITE-A), or + /// null when the node carries no cluster-scoped role. + /// + string? ClusterId { get; } + /// Checks if the local node has the specified role. /// Role name to check. /// True if the local node has the role; otherwise, false. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ClusterRoleInfoTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ClusterRoleInfoTests.cs new file mode 100644 index 00000000..9080886a --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ClusterRoleInfoTests.cs @@ -0,0 +1,105 @@ +using Akka.Actor; +using Akka.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; + +/// +/// Guards / — the +/// node's OWN cluster-scoped role (per-cluster mesh, Phase 6), derived from the node's configured +/// at construction time, NOT from live cluster membership. +/// That distinction matters: this identity must be readable before the cluster forms, so later +/// Phase-6 work (singleton registration, a startup validator) can act on it at host-build time. +/// +public sealed class ClusterRoleInfoTests +{ + private static AkkaClusterOptions SampleOptions(params string[] roles) => new() + { + Port = 0, + Hostname = "127.0.0.1", + PublicHostname = "127.0.0.1", + SeedNodes = Array.Empty(), + Roles = roles, + }; + + /// + /// Boots a real host through the production + /// entry point (mirrors SplitBrainResolverActivationTests) — 's + /// constructor needs a real clustered to subscribe to cluster events, + /// even though the properties under test never touch live membership — resolves + /// from DI, and returns its (ClusterRole, ClusterId) pair + /// after tearing the host down. Both start/stop live in this helper rather than the test methods + /// so the CancellationToken-less calls stay off xunit1051's radar (mirrors + /// SplitBrainResolverActivationTests.EffectiveConfigAsync). + /// + private static async Task<(string? ClusterRole, string? ClusterId)> ResolveAsync(AkkaClusterOptions options) + { + var builder = Host.CreateDefaultBuilder(); + builder.ConfigureServices(services => + { + services.AddSingleton>(Options.Create(options)); + services.AddAkka("otopcua-role-info-test", (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp)); + services.AddSingleton(); + }); + + using var host = builder.Build(); + await host.StartAsync(); + try + { + var roleInfo = host.Services.GetRequiredService(); + return (roleInfo.ClusterRole, roleInfo.ClusterId); + } + finally + { + await host.StopAsync(); + } + } + + /// A node configured with a single cluster-scoped role exposes it and its ClusterId. + [Fact] + public async Task Single_cluster_role_exposes_role_and_id() + { + var (clusterRole, clusterId) = await ResolveAsync(SampleOptions("driver", "cluster-SITE-A")); + + clusterRole.ShouldBe("cluster-SITE-A"); + clusterId.ShouldBe("SITE-A"); + } + + /// A node configured with only fixed roles carries no cluster identity. + [Fact] + public async Task No_cluster_role_configured_yields_null() + { + var (clusterRole, clusterId) = await ResolveAsync(SampleOptions("admin", "driver")); + + clusterRole.ShouldBeNull(); + clusterId.ShouldBeNull(); + } + + /// + /// More than one cluster-scoped role is a misconfiguration; the first configured wins rather + /// than throwing. + /// + [Fact] + public async Task Multiple_cluster_roles_first_wins() + { + var (clusterRole, clusterId) = await ResolveAsync(SampleOptions("driver", "cluster-MAIN", "cluster-SITE-B")); + + clusterRole.ShouldBe("cluster-MAIN"); + clusterId.ShouldBe("MAIN"); + } + + /// An empty role list carries no cluster identity either. + [Fact] + public async Task Empty_roles_yields_null() + { + var (clusterRole, clusterId) = await ResolveAsync(SampleOptions()); + + clusterRole.ShouldBeNull(); + clusterId.ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs index 74cac042..986bcc76 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs @@ -193,6 +193,10 @@ public sealed class ServiceCollectionExtensionsTests public NodeId LocalNode { get; } = NodeId.Parse("test-node"); /// Gets the local roles. public IReadOnlySet LocalRoles { get; } = new HashSet(["driver"]); + /// Gets the local node's cluster-scoped role. None configured in this fake. + public string? ClusterRole => null; + /// Gets the ClusterId extracted from . None configured in this fake. + public string? ClusterId => null; /// Determines whether the local node has the specified role. /// The role to check. public bool HasRole(string role) => LocalRoles.Contains(role);