Merge branch 'fix/r3-04-one-role-authority' — R3-04: EffectiveRoles is the one role authority (Cluster:Roles wins over OTOPCUA_ROLES, normalised, validated, fail-start on unknown/overlay-changed roles); Program.cs + all four validators consume it; case-insensitive gossip predicates; source-scan guard (archreview 01/S-12 = 03/C5, 01/C-9)
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Cluster
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@@ -146,7 +147,7 @@ else
|
||||
|
||||
// Union with current Akka driver members so a freshly-joined node that has no
|
||||
// NodeDeploymentState row yet still appears as "waiting".
|
||||
var akkaDrivers = Cluster.MembersWithRole("driver")
|
||||
var akkaDrivers = Cluster.MembersWithRole(RoleParser.Driver)
|
||||
.Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var allNodes = byNode.Keys.Union(akkaDrivers, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
@@ -156,7 +157,7 @@ else
|
||||
byNode.TryGetValue(nodeId, out var state);
|
||||
return new NodeRow(
|
||||
NodeId: nodeId,
|
||||
Roles: akkaDrivers.Contains(nodeId) ? new[] { "driver" } : Array.Empty<string>(),
|
||||
Roles: akkaDrivers.Contains(nodeId) ? new[] { RoleParser.Driver } : Array.Empty<string>(),
|
||||
Status: state?.Status,
|
||||
StartedAtUtc: state?.StartedAtUtc,
|
||||
AppliedAtUtc: state?.AppliedAtUtc,
|
||||
|
||||
+4
-2
@@ -181,10 +181,12 @@ public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimer
|
||||
public static IReadOnlyList<(string Host, int Port)> FilterOwnClusterDriverMembers(
|
||||
IEnumerable<Member> members, string? ownClusterRole)
|
||||
{
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// 'Driver' (rolling-upgrade window) must not read as a phantom RunningNodeHasNoRow finding.
|
||||
var driverMembers = members
|
||||
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(DriverRole));
|
||||
.Where(m => m.Status == MemberStatus.Up && RoleParser.GossipedRolesHave(m.Roles, DriverRole));
|
||||
if (!string.IsNullOrEmpty(ownClusterRole))
|
||||
driverMembers = driverMembers.Where(m => m.Roles.Contains(ownClusterRole));
|
||||
driverMembers = driverMembers.Where(m => RoleParser.GossipedRolesHave(m.Roles, ownClusterRole));
|
||||
return driverMembers
|
||||
.Select(m => (Host: m.Address.Host ?? string.Empty, Port: m.Address.Port ?? 0))
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Host) && a.Port > 0)
|
||||
|
||||
@@ -3,6 +3,7 @@ using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||
|
||||
@@ -122,7 +123,9 @@ public sealed class ManualFailoverService : IManualFailoverService
|
||||
}
|
||||
|
||||
private static List<Member> OrderedDrivers(IEnumerable<Member> members) => members
|
||||
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(RedundancyStateActor.DriverRole))
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// 'Driver' (rolling-upgrade window) must still be a manual-failover candidate.
|
||||
.Where(m => m.Status == MemberStatus.Up && RoleParser.GossipedRolesHave(m.Roles, RedundancyStateActor.DriverRole))
|
||||
.OrderBy(m => m, Member.AgeOrdering)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -190,7 +190,15 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
|
||||
if (string.IsNullOrWhiteSpace(host)) continue;
|
||||
|
||||
var isPrimary = driverPrimary is not null && driverPrimary == member.Address;
|
||||
var role = member.Roles.Contains(DriverRole)
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer running an older build
|
||||
// can still gossip a miscased 'Driver' during a rolling upgrade, and this list is what
|
||||
// /hosts and /fleet render, so a miscased-but-genuinely-driver member must not read as
|
||||
// Detached. NOTE: this does NOT reach SelectDriverPrimary/driverPrimary above — that
|
||||
// delegates to ClusterActiveNode.OldestUpMember (ZB.MOM.WW.Health.Akka), which matches via
|
||||
// Akka's own ordinal Member.HasRole and is not this repo's to change (see R3-04 execution
|
||||
// notes). So a member gossiping only 'Driver' now classifies Secondary rather than
|
||||
// Detached, but a PAIR that BOTH gossip 'Driver' still elects no Primary.
|
||||
var role = RoleParser.GossipedRolesHave(member.Roles, DriverRole)
|
||||
? (isPrimary ? CommonsRedundancyRole.Primary : CommonsRedundancyRole.Secondary)
|
||||
: CommonsRedundancyRole.Detached;
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public const string AdminRole = "admin";
|
||||
// R3-04 (theme 4 guard): alias RoleParser.Admin rather than duplicating the literal — the same
|
||||
// pattern Runtime/ServiceCollectionExtensions.DriverRole already follows for RoleParser.Driver.
|
||||
public const string AdminRole = RoleParser.Admin;
|
||||
|
||||
public const string ConfigPublishSingletonName = "config-publish";
|
||||
public const string AdminOperationsSingletonName = "admin-operations";
|
||||
|
||||
@@ -57,13 +57,19 @@ using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
var hasAdmin = roles.Contains("admin");
|
||||
var hasDriver = roles.Contains("driver");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// The SINGLE role authority: Cluster:Roles first, OTOPCUA_ROLES as the fallback (archreview
|
||||
// 01/S-12 = 03/C5, R3-04). Resolved off builder.Configuration BEFORE the per-role overlay is
|
||||
// added, because the overlay's FILENAME depends on the answer — which is why a role overlay may
|
||||
// not itself set Cluster:Roles (AkkaClusterOptionsValidator fails start if one ever does).
|
||||
// EffectiveRoles drives the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.
|
||||
var effectiveRoles = EffectiveRoles.Resolve(builder.Configuration);
|
||||
var roles = effectiveRoles.Gossiped;
|
||||
var hasAdmin = effectiveRoles.HasAdmin;
|
||||
var hasDriver = effectiveRoles.HasDriver;
|
||||
builder.Services.AddSingleton(effectiveRoles);
|
||||
|
||||
// Razor class library static assets (_content/<libname>/...) only auto-enable in
|
||||
// the Development environment. Opt in explicitly so the AdminUI's CSS/JS works
|
||||
// regardless of ASPNETCORE_ENVIRONMENT.
|
||||
@@ -77,7 +83,7 @@ builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost");
|
||||
|
||||
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
||||
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
|
||||
var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
||||
var roleSuffix = roles.Count == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
||||
if (roleSuffix is not null)
|
||||
{
|
||||
builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true);
|
||||
@@ -705,8 +711,16 @@ app.MapOtOpcUaMeshGrpcServices(
|
||||
app.MapOtOpcUaHealth();
|
||||
app.MapOtOpcUaMetrics();
|
||||
|
||||
Log.Information("OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver})",
|
||||
string.Join(",", roles), hasAdmin, hasDriver);
|
||||
Log.Information(
|
||||
"OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver}) source={Source}",
|
||||
string.Join(",", roles), hasAdmin, hasDriver, effectiveRoles.Source);
|
||||
|
||||
// R3-04 choice 5: Cluster:Roles and OTOPCUA_ROLES set to different normalized role sets is almost
|
||||
// certainly not what the operator intended (Cluster:Roles still wins outright — this is
|
||||
// observability, not a behaviour change). Logged here rather than inside the static factory because
|
||||
// Serilog is not configured yet when EffectiveRoles.Resolve runs at the top of this file.
|
||||
if (effectiveRoles.DisagreementWarning is not null)
|
||||
Log.Warning("{Warning}", effectiveRoles.DisagreementWarning);
|
||||
|
||||
// #561 — startup growth self-check: the banner above went through the live pipeline; verify each
|
||||
// configured file sink physically grew. A sink whose writes silently fail (the wonder-app-vd03
|
||||
|
||||
@@ -5,6 +5,7 @@ using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
@@ -1648,8 +1649,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
if (_driverMemberCountProvider is not null) return _driverMemberCountProvider();
|
||||
try
|
||||
{
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// 'Driver' (rolling-upgrade window) must still count toward the R2-04 primary-gate member
|
||||
// count, or a genuine 2-node pair silently takes the single-node allow branch.
|
||||
return Akka.Cluster.Cluster.Get(Context.System).State.Members
|
||||
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up && m.Roles.Contains(ServiceCollectionExtensions.DriverRole));
|
||||
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up
|
||||
&& RoleParser.GossipedRolesHave(m.Roles, ServiceCollectionExtensions.DriverRole));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
@@ -338,7 +339,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
var isWindowsOnly = driverType is "Galaxy";
|
||||
if (!OperatingSystem.IsWindows() && isWindowsOnly) return true;
|
||||
if (roles.Contains("dev") && isWindowsOnly) return true;
|
||||
// R3-04 (theme 4 guard): GossipedRolesHave, not a bare "dev" literal Contains — the same
|
||||
// case-insensitivity the rest of the role authority gives every other consumer.
|
||||
if (RoleParser.GossipedRolesHave(roles, RoleParser.Dev) && isWindowsOnly) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
@@ -516,8 +517,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
if (_driverMemberCountProvider is not null) return _driverMemberCountProvider();
|
||||
try
|
||||
{
|
||||
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
|
||||
// 'Driver' (rolling-upgrade window) must still count toward the alerts-emit gate.
|
||||
return Akka.Cluster.Cluster.Get(Context.System).State.Members
|
||||
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up && m.Roles.Contains(ServiceCollectionExtensions.DriverRole));
|
||||
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up
|
||||
&& RoleParser.GossipedRolesHave(m.Roles, ServiceCollectionExtensions.DriverRole));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user