From a41582ea6a8da2e1ca8bb459fa5a430d0cb8faca Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 11:38:40 -0400 Subject: [PATCH] feat(mesh-phase4): register ConfigDb only on admin-role nodes Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Health/HealthEndpoints.cs | 35 +++++-- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 16 +++- .../DriverOnlyNoConfigDbBootTests.cs | 95 +++++++++++++++++++ 3 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs index 4ad7385e..903fb92e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs @@ -17,21 +17,37 @@ public static class HealthEndpoints { /// /// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on - /// ready+active; admin-leader on active only. + /// ready+active; admin-leader on active only. The configdb probe is admin-only (per-cluster mesh + /// Phase 4): a driver-only node holds no ConfigDb, so it is registered iff . /// /// The service collection to register the health checks on. + /// + /// Whether this node carries the admin role. When false (a driver-only node) the + /// configdb probe is skipped — the node registers no + /// factory, so probing it would throw when the readiness endpoint resolves the factory. Defaults to + /// true so admin/fused callers (including the test harnesses) keep the probe unchanged. + /// /// The same service collection, for chaining. - public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services) + public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services, bool hasAdmin = true) { - services.AddHealthChecks() - .AddTypeActivatedCheck>( + var checks = services.AddHealthChecks(); + + // configdb probe is admin-only (per-cluster mesh Phase 4). A driver-only node gets its config + // from central via ConfigSource:Mode=FetchAndCache and never registers the ConfigDb factory, so + // the type-activated DatabaseHealthCheck would throw resolving it. Admin + fused nodes: unchanged. + if (hasAdmin) + { + checks.AddTypeActivatedCheck>( "configdb", failureStatus: null, tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active }, args: new DatabaseHealthCheckOptions { ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct), - }) + }); + } + + checks .AddTypeActivatedCheck( "akka", failureStatus: null, @@ -42,11 +58,10 @@ public static class HealthEndpoints failureStatus: null, tags: new[] { ZbHealthTags.Active }, args: "admin") - // Registered unconditionally, not driver-gated. AddOtOpcUaHealth takes no role argument - // and runs on every node; the check itself resolves ISyncStatus optionally and reports - // Healthy when LocalDb is absent (admin-only graphs) or replication is default-OFF, so a - // plain node is never degraded by it. A factory registration keeps this no-arg signature - // while still reading ISyncStatus + options + the sync port from the container. + // Registered on every node regardless of role (unlike the admin-only configdb probe above): + // the check itself resolves ISyncStatus optionally and reports Healthy when LocalDb is absent + // (admin-only graphs) or replication is default-OFF, so a plain node is never degraded by it. + // A factory registration reads ISyncStatus + options + the sync port from the container. .Add(new HealthCheckRegistration( "localdb-replication", sp => new LocalDbReplicationHealthCheck( diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 74fb072b..0ed23bda 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -122,8 +122,16 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua"); // Windows-service registration is handled at install time by scripts/install/Install-Services.ps1 // rather than in-process, so the binary stays cross-platform-compilable. -// Shared services — always registered regardless of role. ConfigDb is required for everything. -builder.Services.AddOtOpcUaConfigDb(builder.Configuration); +// ConfigDb is admin-only now (per-cluster mesh Phase 4). Only the admin role owns a ConfigDb +// connection string; a driver-only node holds none at all and gets its deployed configuration from +// central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it +// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string. +// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected. +if (hasAdmin) + builder.Services.AddOtOpcUaConfigDb(builder.Configuration); +else + Log.Information("Driver-only node — no ConfigDb registered; configuration via ConfigSource:Mode=FetchAndCache"); + builder.Services.AddOtOpcUaCluster(builder.Configuration); // Validate LdapOptions unconditionally so ANY role node (admin-only, driver-only, or fused) @@ -392,7 +400,9 @@ if (hasAdmin) // Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration. builder.Services.AddOtOpcUaSecrets(builder.Configuration); -builder.Services.AddOtOpcUaHealth(); +// hasAdmin gates the configdb probe: a driver-only node registers no ConfigDb factory (see above), so +// probing it would throw when the readiness endpoint resolves the factory. +builder.Services.AddOtOpcUaHealth(hasAdmin); builder.Services.AddOtOpcUaObservability(builder.Configuration); // gRPC server plumbing shared by two endpoints: the LocalDb passive sync endpoint (driver-role, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs new file mode 100644 index 00000000..954ddadc --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// Per-cluster mesh Phase 4 — ConfigDb is registered iff the node has the admin role. +/// A driver-only node (Akka role driver, NOT admin) boots with NO ConfigDb +/// connection string at all; its configuration arrives from central via the Phase-3 +/// ConfigSource:Mode=FetchAndCache path. +/// +/// +/// +/// These tests replicate the exact ConfigDb gating decision Program.cs makes — +/// if (hasAdmin) services.AddOtOpcUaConfigDb(configuration) — over the real +/// method, for both role shapes. +/// They deliberately do NOT boot the full host: Program.cs reads roles from the +/// OTOPCUA_ROLES process env var and starting the host triggers the Akka cluster join +/// (see TwoNodeClusterHarness on why WebApplicationFactory<Program> is not +/// driven here, and LocalDbWiringTests for the same partial-graph harness model). The +/// full driver-only boot is proven by the Phase-4 live gate + the later DI-graph sweep task. +/// +/// +public sealed class DriverOnlyNoConfigDbBootTests : IDisposable +{ + private readonly List _apps = []; + + /// + /// Builds the shared-services graph the way Program.cs gates ConfigDb: it is registered iff + /// . A driver-only node passes hasAdmin: false and supplies + /// no ConnectionStrings:ConfigDb at all. + /// + private IServiceProvider BuildSharedServicesGraph(bool hasAdmin, string? configDbConnectionString) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + if (configDbConnectionString is not null) + { + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:ConfigDb"] = configDbConnectionString, + }); + } + + if (hasAdmin) + builder.Services.AddOtOpcUaConfigDb(builder.Configuration); + + var app = builder.Build(); + _apps.Add(app); + return app.Services; + } + + [Fact] + public void DriverOnlyNode_NoConnectionString_BuildsWithoutThrowing_AndRegistersNoConfigDbFactory() + { + // The core Phase-4 guarantee: a driver-only node holds no ConfigDb connection string and must + // still build its service graph without throwing — nothing in the shared registrations may + // reach for the ConfigDb factory. + IServiceProvider sp = null!; + Should.NotThrow(() => sp = BuildSharedServicesGraph(hasAdmin: false, configDbConnectionString: null)); + + sp.GetService>().ShouldBeNull(); + } + + [Fact] + public void AdminNode_WithConnectionString_RegistersTheConfigDbFactory() + { + // The fused/admin path is byte-for-byte unaffected: ConfigDb is registered exactly as before. + var sp = BuildSharedServicesGraph( + hasAdmin: true, + configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True"); + + sp.GetService>().ShouldNotBeNull(); + } + + [Fact] + public void AddOtOpcUaConfigDb_WithoutConnectionString_Throws() + { + // Why the gate exists: the unconditional call Program.cs made throws on a driver-only node, + // which has no ConfigDb connection string. This pins the exact failure the hasAdmin gate avoids. + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + + Should.Throw(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration)); + } + + public void Dispose() + { + foreach (var app in _apps) + ((IDisposable)app).Dispose(); + } +}