feat(cluster): EffectiveRoles — one role authority with one precedence + two-layer normalization (archreview 01/S-12, 01/C-9)

This commit is contained in:
Joseph Doherty
2026-08-20 09:41:54 -04:00
parent a011a53329
commit 7702616716
4 changed files with 388 additions and 8 deletions
@@ -0,0 +1,164 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>Which of the two role sources <see cref="EffectiveRoles"/> resolved from.</summary>
public enum RoleSource
{
/// <summary>Neither source named any role.</summary>
None,
/// <summary><c>Cluster:Roles</c> was non-empty and won outright.</summary>
Configured,
/// <summary><c>Cluster:Roles</c> was empty; <c>OTOPCUA_ROLES</c> supplied the roles.</summary>
Environment,
}
/// <summary>The node's effective cluster roles — the SINGLE authority for "what is this node".
/// Resolved once, from one documented precedence, and consumed by BOTH the Host's conditional
/// wiring and the Akka role list, so the two cannot disagree (archreview 01/S-12 = 03/C5).</summary>
/// <remarks>
/// <para><b>Precedence:</b> <c>Cluster:Roles</c> when non-empty, else <c>OTOPCUA_ROLES</c>,
/// else empty. Unchanged from <see cref="RoleParser.ResolveEffectiveRoles(IReadOnlyList{string}?, string?)"/>
/// — what changed is that <c>Program.cs</c> now obeys it too, instead of reading the environment
/// directly.</para>
/// <para><b>Normalization is two-layer, on purpose.</b> <see cref="Gossiped"/> lower-cases plain
/// roles (Akka matches gossiped role names ORDINALLY, and every consumer compares against a
/// lowercase constant) but keeps a cluster role's ClusterId suffix VERBATIM — lower-casing that
/// would rename <c>cluster-SITE-A</c> on a running fleet, and a rolling pair upgrade would gossip
/// two different role names mid-window. Lower-casing a plain role changes the wire only for a
/// node that is already broken (archreview 01/S-12 = 03/C5, 01/C-9).</para>
/// <para><b>Validation runs on BOTH sources.</b> The environment path was always validated by
/// <see cref="RoleParser.Parse"/>; the configured path previously accepted anything verbatim
/// (<c>Cluster:Roles: ["Drivr"]</c> silently gossiped a role that matched nothing). Both now fail
/// fast, naming the offending key, on an unknown role — a miscased but KNOWN role still
/// normalizes silently rather than failing, or an operator's <c>Driver</c> would brick the node
/// at the exact moment they upgrade.</para>
/// </remarks>
public sealed class EffectiveRoles
{
private EffectiveRoles(IReadOnlyList<string> gossiped, RoleSource source)
{
Gossiped = gossiped;
Source = source;
ClusterRole = RoleParser.ClusterRoleFromRoles(gossiped);
ClusterId = ClusterRole is null ? null : RoleParser.ClusterIdFromRole(ClusterRole);
}
/// <summary>The configuration key <see cref="Resolve(IConfiguration)"/> reads for the configured
/// side of the precedence.</summary>
public const string ConfiguredRolesKey = "Cluster:Roles";
/// <summary>The roles as they are handed to Akka — <see cref="AkkaClusterOptions.Roles"/> becomes
/// this list, unmodified.</summary>
public IReadOnlyList<string> Gossiped { get; }
/// <summary>Which source resolved these roles.</summary>
public RoleSource Source { get; }
/// <summary>True when <see cref="RoleParser.Admin"/> is among <see cref="Gossiped"/> (case-insensitive).</summary>
public bool HasAdmin => Has(RoleParser.Admin);
/// <summary>True when <see cref="RoleParser.Driver"/> is among <see cref="Gossiped"/> (case-insensitive).</summary>
public bool HasDriver => Has(RoleParser.Driver);
/// <summary>True when <see cref="RoleParser.Dev"/> is among <see cref="Gossiped"/> (case-insensitive).</summary>
public bool HasDev => Has(RoleParser.Dev);
/// <summary>True when <paramref name="role"/> is among <see cref="Gossiped"/>, compared case-insensitively.</summary>
/// <param name="role">The role to test for.</param>
public bool Has(string role) =>
Gossiped.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
/// <summary>The first cluster-scoped role in <see cref="Gossiped"/>, original spelling preserved,
/// or null when this node carries none.</summary>
public string? ClusterRole { get; }
/// <summary>The ClusterId this node belongs to (the suffix of <see cref="ClusterRole"/>), or null
/// for a legacy / single-mesh node.</summary>
public string? ClusterId { get; }
/// <summary>The one factory: resolves the effective roles off <paramref name="configuration"/>'s
/// <c>Cluster:Roles</c> and the process's <c>OTOPCUA_ROLES</c> environment variable.</summary>
/// <param name="configuration">The application configuration.</param>
/// <returns>The resolved <see cref="EffectiveRoles"/>.</returns>
/// <exception cref="ArgumentException">Either source names an unknown role.</exception>
public static EffectiveRoles Resolve(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var configured = configuration.GetSection(ConfiguredRolesKey).Get<string[]>();
var envRoles = Environment.GetEnvironmentVariable(RoleParser.RolesEnvVar);
return Resolve(configured, envRoles);
}
/// <summary>Resolves the effective roles from explicit values — the overload
/// <see cref="Resolve(IConfiguration)"/> delegates to.</summary>
/// <param name="configured">The bound <c>Cluster:Roles</c> value.</param>
/// <param name="envRoles">The raw <c>OTOPCUA_ROLES</c> value.</param>
/// <returns>The resolved <see cref="EffectiveRoles"/>.</returns>
/// <exception cref="ArgumentException">Either source names an unknown role.</exception>
public static EffectiveRoles Resolve(IReadOnlyList<string>? configured, string? envRoles)
{
if (configured is { Count: > 0 })
{
return new EffectiveRoles(NormalizeAndValidateConfigured(configured), RoleSource.Configured);
}
if (!string.IsNullOrWhiteSpace(envRoles))
{
// RoleParser.Parse already trims, lower-cases (fully — including a cluster suffix, which
// is intentionally different from the configured path; see ClusterIdFromRole's remarks),
// dedupes and validates.
return new EffectiveRoles(RoleParser.Parse(envRoles), RoleSource.Environment);
}
return new EffectiveRoles(Array.Empty<string>(), RoleSource.None);
}
/// <summary>Normalizes and validates the <c>Cluster:Roles</c> entries: a plain role is trimmed and
/// lower-cased; a cluster role keeps its ClusterId suffix verbatim but gets a normalized
/// (lower-case) <c>cluster-</c> prefix. Throws naming the offending index and value on anything
/// that is neither.</summary>
private static string[] NormalizeAndValidateConfigured(IReadOnlyList<string> configured)
{
var result = new string[configured.Count];
for (var i = 0; i < configured.Count; i++)
{
var raw = configured[i];
var trimmed = raw?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
throw Unknown(i, raw);
}
if (RoleParser.IsClusterRole(trimmed))
{
var clusterId = RoleParser.ClusterIdFromRole(trimmed);
if (clusterId.Length == 0)
{
throw Unknown(i, raw);
}
result[i] = RoleParser.ClusterRolePrefix + clusterId;
continue;
}
var lowered = trimmed.ToLowerInvariant();
if (lowered is not (RoleParser.Admin or RoleParser.Driver or RoleParser.Dev))
{
throw Unknown(i, raw);
}
result[i] = lowered;
}
return result;
}
private static ArgumentException Unknown(int index, string? raw) =>
new($"{ConfiguredRolesKey}[{index}] = '{raw}' is not a known role. Allowed: {RoleParser.Admin}, "
+ $"{RoleParser.Driver}, {RoleParser.Dev}, or '{RoleParser.ClusterRolePrefix}<ClusterId>'.");
}
@@ -111,12 +111,15 @@ public static class RoleParser
/// <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">The environment value names an unknown role.</exception>
public static string[] ResolveEffectiveRoles(IReadOnlyList<string>? configuredRoles, string? envRoles)
{
if (configuredRoles is { Count: > 0 }) return [.. configuredRoles];
return Parse(envRoles);
}
/// <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 (archreview R3-04). <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>