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,
};
/// Returns true when is a cluster-scoped role — starts with
/// and carries a non-empty ClusterId suffix.
///
/// The prefix test is case-insensitive. It was ordinal, which meant a
/// Cluster:Roles entry spelled Cluster-SITE-A — that path binds verbatim, unlike
/// the path, which lower-cases — classified as an ordinary role. The node
/// then resolved a ClusterId while still gossiping a cluster-scoped role,
/// so it looked split-topology to its peers and un-scoped to itself.
/// pre-normalized for exactly this reason;
/// ClusterRoleInfo did not, and got the null.
///
/// The role name to test.
/// if the role is a cluster-scoped role.
public static bool IsClusterRole(string role) =>
role is not null
&& role.StartsWith(ClusterRolePrefix, StringComparison.OrdinalIgnoreCase)
&& role.Length > ClusterRolePrefix.Length;
/// Extracts the ClusterId from a cluster-scoped role, e.g. cluster-SITE-A → SITE-A.
/// Assumes is true for .
///
/// The suffix keeps the operator's own spelling. It is NOT case-normalized here because the two
/// role sources disagree by construction — Cluster:Roles binds verbatim
/// (cluster-SITE-A ⇒ SITE-A) while lower-cases the
/// OTOPCUA_ROLES path (⇒ site-a) — and normalizing one side would silently rename
/// the Akka role of a running deployment (docker-dev's rig configures cluster-MAIN /
/// cluster-SITE-A verbatim today). Every consumer of a ClusterId therefore compares it
/// case-insensitively; see the identity rule on AclScopeRef.
///
/// The cluster-scoped role name.
/// The ClusterId portion of the role, with the role prefix removed.
public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..];
///
/// The cluster-scoped roles in , in the order given. More than one is a
/// misconfiguration the caller is expected to report — a node belongs to exactly one mesh.
///
/// The node's roles.
/// The cluster-scoped roles, original spelling preserved.
public static IReadOnlyList ClusterRolesIn(IEnumerable? roles) =>
roles is null ? Array.Empty() : roles.Where(IsClusterRole).ToArray();
/// The first cluster-scoped role in , or null when there is none.
/// The node's roles.
/// The cluster role, e.g. cluster-SITE-A.
public static string? ClusterRoleFromRoles(IEnumerable? roles) =>
roles?.FirstOrDefault(IsClusterRole);
///
/// The ClusterId this node belongs to, derived from its own roles; null when it carries no
/// cluster-scoped role (a legacy / single-mesh node).
///
///
/// The single home for a derivation that was open-coded at four call sites before
/// (ClusterRoleInfo, the ControlPlane singleton scope, SplitTopologyTransportValidator
/// and ConfigDbApiKeyStore) — each with its own view of whether the classification was
/// case-sensitive, which is how the mixed-case gap above survived in three of the four.
///
/// The node's roles.
/// The ClusterId, or .
public static string? ClusterIdFromRoles(IEnumerable? roles)
{
var role = ClusterRoleFromRoles(roles);
return role is null ? null : ClusterIdFromRole(role);
}
///
/// Resolves a node's effective Akka roles: Cluster:Roles when it is non-empty,
/// otherwise the OTOPCUA_ROLES environment variable through .
///
///
///
/// ⚠️ This fallback was documented for a year before it existed.
/// AkkaClusterOptions.Roles' own XML doc and
/// both described it; only the validator implemented it, privately. A node configured solely
/// through OTOPCUA_ROLES — which drives every bit of Program.cs's conditional
/// wiring, so it reads as the authoritative knob — joined the cluster carrying no Akka
/// roles at all. It ran, served, and logged nothing wrong, while central reported "no
/// driver-role nodes are Up" and every deployment to it timed out. That is not hypothetical:
/// see docs/deployments/wonder-app-vd03-makino-z-34184.md, where the field fix was to
/// hand-add Cluster__Roles__0/1 to a Windows service's registry environment.
///
///
/// Configured wins outright — a stray OTOPCUA_ROLES can never widen an explicit
/// Cluster:Roles. The two sources normalize differently on purpose (see
/// ), so a redundant pair should be configured through the
/// same one.
///
///
/// The bound Cluster:Roles value.
/// The raw OTOPCUA_ROLES value.
/// The effective roles; empty when neither source names any.
/// Either source names an unknown role.
///
/// Thin delegation to —
/// the single role authority. is the type to
/// reach for directly; this overload is retained so existing callers of the array-returning
/// shape keep compiling.
///
public static string[] ResolveEffectiveRoles(IReadOnlyList? configuredRoles, string? envRoles) =>
[.. EffectiveRoles.Resolve(configuredRoles, envRoles).Gossiped];
/// Reads the effective roles for the current process, resolving the environment itself.
/// The bound Cluster:Roles value.
/// The effective roles.
public static string[] ResolveEffectiveRoles(IReadOnlyList? configuredRoles) =>
ResolveEffectiveRoles(configuredRoles, Environment.GetEnvironmentVariable(RolesEnvVar));
/// The environment variable the role list falls back to when Cluster:Roles is empty.
public const string RolesEnvVar = "OTOPCUA_ROLES";
/// 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.
public static string[] Parse(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty();
var roles = raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(r => r.ToLowerInvariant())
.Distinct()
.ToArray();
foreach (var r in roles)
{
if (!Allowed.Contains(r) && !IsClusterRole(r))
throw new ArgumentException(
$"Unknown role '{r}'. Allowed: {string.Join(", ", Allowed)}, or '{ClusterRolePrefix}'.",
nameof(raw));
}
return roles;
}
/// True when a GOSSIPED role list carries , compared
/// case-insensitively. Akka compares role names ordinally and a PEER may be running an older
/// build that gossips a verbatim 'Driver' (archreview 01/C-9), so every predicate over
/// Member.Roles goes through here rather than List.Contains.
/// The gossiped role list (e.g. Member.Roles).
/// The role to test for.
/// True when is present, case-insensitively.
public static bool GossipedRolesHave(IEnumerable? gossipedRoles, string role) =>
gossipedRoles is not null
&& gossipedRoles.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
}