826312befe
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.
170 lines
9.8 KiB
C#
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));
|
|
}
|