feat(mesh): RoleParser accepts cluster-{ClusterId} roles; consolidate the driver-role literal
Phase 6 (per-cluster mesh split) needs nodes to carry a cluster-scoped role like cluster-SITE-A; RoleParser previously rejected anything outside the fixed admin/driver/dev set. Adds IsClusterRole/ ClusterIdFromRole plus well-known-role constants, and points the three duplicate "driver" string literals (RedundancyStateActor, ClusterNodeAddressReconcilerActor, ServiceCollectionExtensions) at the new RoleParser.Driver so the value has one source, without renaming any existing DriverRole symbol. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -2,11 +2,36 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
public static class RoleParser
|
||||
{
|
||||
/// <summary>The admin-role name.</summary>
|
||||
public const string Admin = "admin";
|
||||
|
||||
/// <summary>The driver-role name. Single source of truth — downstream <c>DriverRole</c>
|
||||
/// constants (<c>RedundancyStateActor</c>, <c>ClusterNodeAddressReconcilerActor</c>,
|
||||
/// <c>ServiceCollectionExtensions</c>) alias this value rather than duplicating the literal.</summary>
|
||||
public const string Driver = "driver";
|
||||
|
||||
/// <summary>The dev-role name.</summary>
|
||||
public const string Dev = "dev";
|
||||
|
||||
/// <summary>Prefix identifying a cluster-scoped role, e.g. <c>cluster-SITE-A</c> (per-cluster mesh, Phase 6).</summary>
|
||||
public const string ClusterRolePrefix = "cluster-";
|
||||
|
||||
private static readonly HashSet<string> Allowed = new(StringComparer.Ordinal)
|
||||
{
|
||||
"admin", "driver", "dev",
|
||||
Admin, Driver, Dev,
|
||||
};
|
||||
|
||||
/// <summary>Returns true when <paramref name="role"/> is a cluster-scoped role — starts with
|
||||
/// <see cref="ClusterRolePrefix"/> and carries a non-empty ClusterId suffix.</summary>
|
||||
/// <param name="role">The role name to test.</param>
|
||||
public static bool IsClusterRole(string role) =>
|
||||
role.StartsWith(ClusterRolePrefix, StringComparison.Ordinal) && role.Length > ClusterRolePrefix.Length;
|
||||
|
||||
/// <summary>Extracts the ClusterId from a cluster-scoped role, e.g. <c>cluster-SITE-A</c> → <c>SITE-A</c>.
|
||||
/// Assumes <see cref="IsClusterRole"/> is true for <paramref name="role"/>.</summary>
|
||||
/// <param name="role">The cluster-scoped role name.</param>
|
||||
public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..];
|
||||
|
||||
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
|
||||
/// <param name="raw">The raw role string to parse.</param>
|
||||
/// <returns>The distinct, lower-cased, validated role names.</returns>
|
||||
@@ -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}<ClusterId>'.",
|
||||
nameof(raw));
|
||||
}
|
||||
|
||||
return roles;
|
||||
|
||||
+2
-1
@@ -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
|
||||
{
|
||||
/// <summary>Driver role name — only members carrying it are expected to have a ClusterNode row.</summary>
|
||||
public const string DriverRole = "driver";
|
||||
public const string DriverRole = RoleParser.Driver;
|
||||
|
||||
/// <summary>Collapses a burst of membership events (a rolling restart) into one reconcile.</summary>
|
||||
public static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(2);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>The cluster role that marks a node as carrying the driver runtime.</summary>
|
||||
public const string DriverRole = "driver";
|
||||
public const string DriverRole = RoleParser.Driver;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the driver Primary: the <b>oldest</b> Up member carrying the <see cref="DriverRole"/>.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -57,4 +57,57 @@ public sealed class RoleParserTests
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => RoleParser.Parse("admin,master"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single cluster-scoped role is accepted.</summary>
|
||||
[Theory]
|
||||
[InlineData("cluster-MAIN")]
|
||||
[InlineData("cluster-SITE-A")]
|
||||
public void Cluster_role_accepted(string role)
|
||||
{
|
||||
RoleParser.Parse(role).ShouldBe(new[] { role.ToLowerInvariant() });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cluster roles mix freely with the fixed roles.</summary>
|
||||
[Fact]
|
||||
public void Fixed_and_cluster_roles_mix()
|
||||
{
|
||||
RoleParser.Parse("driver,cluster-SITE-A").ShouldBe(new[] { "driver", "cluster-site-a" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cluster role with an empty ClusterId ("cluster-") is rejected.</summary>
|
||||
[Fact]
|
||||
public void Empty_cluster_id_throws()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => RoleParser.Parse("cluster-"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies IsClusterRole returns true for valid cluster roles and false otherwise.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies ClusterIdFromRole extracts the ClusterId suffix.</summary>
|
||||
[Fact]
|
||||
public void ClusterIdFromRole_extracts_suffix()
|
||||
{
|
||||
RoleParser.ClusterIdFromRole("cluster-SITE-A").ShouldBe("SITE-A");
|
||||
}
|
||||
|
||||
/// <summary>Verifies the three well-known role constants match the historical literal values.</summary>
|
||||
[Fact]
|
||||
public void Well_known_role_constants()
|
||||
{
|
||||
RoleParser.Admin.ShouldBe("admin");
|
||||
RoleParser.Driver.ShouldBe("driver");
|
||||
RoleParser.Dev.ShouldBe("dev");
|
||||
RoleParser.ClusterRolePrefix.ShouldBe("cluster-");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user