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-A → SITE-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-");
+ }
}