Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs
T
Joseph Doherty 826312befe docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
2026-10-15 06:14:58 -04:00

170 lines
9.8 KiB
C#

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