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)); }