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:
Joseph Doherty
2026-08-20 11:05:09 -04:00
41 changed files with 2176 additions and 159 deletions
+22 -8
View File
@@ -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