diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs index d417ff78..1421543a 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs @@ -2,11 +2,36 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster; public static class RoleParser { + /// The admin-role name. + public const string Admin = "admin"; + + /// The driver-role name. Single source of truth — downstream DriverRole + /// constants (RedundancyStateActor, ClusterNodeAddressReconcilerActor, + /// ServiceCollectionExtensions) alias this value rather than duplicating the literal. + public const string Driver = "driver"; + + /// The dev-role name. + public const string Dev = "dev"; + + /// Prefix identifying a cluster-scoped role, e.g. cluster-SITE-A (per-cluster mesh, Phase 6). + public const string ClusterRolePrefix = "cluster-"; + private static readonly HashSet Allowed = new(StringComparer.Ordinal) { - "admin", "driver", "dev", + Admin, Driver, Dev, }; + /// Returns true when is a cluster-scoped role — starts with + /// and carries a non-empty ClusterId suffix. + /// The role name to test. + public static bool IsClusterRole(string role) => + role.StartsWith(ClusterRolePrefix, StringComparison.Ordinal) && role.Length > ClusterRolePrefix.Length; + + /// Extracts the ClusterId from a cluster-scoped role, e.g. cluster-SITE-ASITE-A. + /// Assumes is true for . + /// The cluster-scoped role name. + public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..]; + /// Parses a comma-separated string of role names into a validated array. /// The raw role string to parse. /// The distinct, lower-cased, validated role names. @@ -22,9 +47,10 @@ public static class RoleParser foreach (var r in roles) { - if (!Allowed.Contains(r)) + if (!Allowed.Contains(r) && !IsClusterRole(r)) throw new ArgumentException( - $"Unknown role '{r}'. Allowed: {string.Join(", ", Allowed)}.", nameof(raw)); + $"Unknown role '{r}'. Allowed: {string.Join(", ", Allowed)}, or '{ClusterRolePrefix}'.", + nameof(raw)); } return roles; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs index cb6326d7..bb7c476e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Cluster; using Akka.Event; using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; @@ -20,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimers { /// Driver role name — only members carrying it are expected to have a ClusterNode row. - public const string DriverRole = "driver"; + public const string DriverRole = RoleParser.Driver; /// Collapses a burst of membership events (a rolling restart) into one reconcile. public static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(2); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs index c7160c5b..d8d69070 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Cluster; using Akka.Cluster.Tools.PublishSubscribe; using Akka.Event; +using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using CommonsRedundancyRole = ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy.RedundancyRole; @@ -112,7 +113,7 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers } /// The cluster role that marks a node as carrying the driver runtime. - public const string DriverRole = "driver"; + public const string DriverRole = RoleParser.Driver; /// /// Selects the driver Primary: the oldest Up member carrying the . diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 062ff3ad..12648380 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -36,7 +36,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime; public static class ServiceCollectionExtensions { - public const string DriverRole = "driver"; + public const string DriverRole = RoleParser.Driver; public const string DriverHostActorName = "driver-host"; public const string DbHealthProbeActorName = "db-health"; diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs index 414bec5f..ceac246b 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs @@ -57,4 +57,57 @@ public sealed class RoleParserTests { Should.Throw(() => RoleParser.Parse("admin,master")); } + + /// Verifies that a single cluster-scoped role is accepted. + [Theory] + [InlineData("cluster-MAIN")] + [InlineData("cluster-SITE-A")] + public void Cluster_role_accepted(string role) + { + RoleParser.Parse(role).ShouldBe(new[] { role.ToLowerInvariant() }); + } + + /// Verifies that cluster roles mix freely with the fixed roles. + [Fact] + public void Fixed_and_cluster_roles_mix() + { + RoleParser.Parse("driver,cluster-SITE-A").ShouldBe(new[] { "driver", "cluster-site-a" }); + } + + /// Verifies that a cluster role with an empty ClusterId ("cluster-") is rejected. + [Fact] + public void Empty_cluster_id_throws() + { + Should.Throw(() => RoleParser.Parse("cluster-")); + } + + /// Verifies IsClusterRole returns true for valid cluster roles and false otherwise. + [Theory] + [InlineData("cluster-MAIN", true)] + [InlineData("cluster-SITE-A", true)] + [InlineData("cluster-", false)] + [InlineData("driver", false)] + [InlineData("admin", false)] + [InlineData("clusterx", false)] + public void IsClusterRole_classifies_correctly(string role, bool expected) + { + RoleParser.IsClusterRole(role).ShouldBe(expected); + } + + /// Verifies ClusterIdFromRole extracts the ClusterId suffix. + [Fact] + public void ClusterIdFromRole_extracts_suffix() + { + RoleParser.ClusterIdFromRole("cluster-SITE-A").ShouldBe("SITE-A"); + } + + /// Verifies the three well-known role constants match the historical literal values. + [Fact] + public void Well_known_role_constants() + { + RoleParser.Admin.ShouldBe("admin"); + RoleParser.Driver.ShouldBe("driver"); + RoleParser.Dev.ShouldBe("dev"); + RoleParser.ClusterRolePrefix.ShouldBe("cluster-"); + } }