feat(mesh-phase4): register ConfigDb only on admin-role nodes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -17,21 +17,37 @@ public static class HealthEndpoints
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
|
/// 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 <paramref name="hasAdmin"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The service collection to register the health checks on.</param>
|
/// <param name="services">The service collection to register the health checks on.</param>
|
||||||
|
/// <param name="hasAdmin">
|
||||||
|
/// Whether this node carries the <c>admin</c> role. When <c>false</c> (a driver-only node) the
|
||||||
|
/// <c>configdb</c> probe is skipped — the node registers no <see cref="OtOpcUaConfigDbContext"/>
|
||||||
|
/// factory, so probing it would throw when the readiness endpoint resolves the factory. Defaults to
|
||||||
|
/// <c>true</c> so admin/fused callers (including the test harnesses) keep the probe unchanged.
|
||||||
|
/// </param>
|
||||||
/// <returns>The same service collection, for chaining.</returns>
|
/// <returns>The same service collection, for chaining.</returns>
|
||||||
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services)
|
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services, bool hasAdmin = true)
|
||||||
{
|
{
|
||||||
services.AddHealthChecks()
|
var checks = services.AddHealthChecks();
|
||||||
.AddTypeActivatedCheck<DatabaseHealthCheck<OtOpcUaConfigDbContext>>(
|
|
||||||
|
// 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<DatabaseHealthCheck<OtOpcUaConfigDbContext>>(
|
||||||
"configdb",
|
"configdb",
|
||||||
failureStatus: null,
|
failureStatus: null,
|
||||||
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
||||||
args: new DatabaseHealthCheckOptions<OtOpcUaConfigDbContext>
|
args: new DatabaseHealthCheckOptions<OtOpcUaConfigDbContext>
|
||||||
{
|
{
|
||||||
ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct),
|
ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct),
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checks
|
||||||
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
|
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
|
||||||
"akka",
|
"akka",
|
||||||
failureStatus: null,
|
failureStatus: null,
|
||||||
@@ -42,11 +58,10 @@ public static class HealthEndpoints
|
|||||||
failureStatus: null,
|
failureStatus: null,
|
||||||
tags: new[] { ZbHealthTags.Active },
|
tags: new[] { ZbHealthTags.Active },
|
||||||
args: "admin")
|
args: "admin")
|
||||||
// Registered unconditionally, not driver-gated. AddOtOpcUaHealth takes no role argument
|
// Registered on every node regardless of role (unlike the admin-only configdb probe above):
|
||||||
// and runs on every node; the check itself resolves ISyncStatus optionally and reports
|
// the check itself resolves ISyncStatus optionally and reports Healthy when LocalDb is absent
|
||||||
// Healthy when LocalDb is absent (admin-only graphs) or replication is default-OFF, so a
|
// (admin-only graphs) or replication is default-OFF, so a plain node is never degraded by it.
|
||||||
// plain node is never degraded by it. A factory registration keeps this no-arg signature
|
// A factory registration reads ISyncStatus + options + the sync port from the container.
|
||||||
// while still reading ISyncStatus + options + the sync port from the container.
|
|
||||||
.Add(new HealthCheckRegistration(
|
.Add(new HealthCheckRegistration(
|
||||||
"localdb-replication",
|
"localdb-replication",
|
||||||
sp => new LocalDbReplicationHealthCheck(
|
sp => new LocalDbReplicationHealthCheck(
|
||||||
|
|||||||
@@ -122,8 +122,16 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
|
|||||||
// Windows-service registration is handled at install time by scripts/install/Install-Services.ps1
|
// 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.
|
// rather than in-process, so the binary stays cross-platform-compilable.
|
||||||
|
|
||||||
// Shared services — always registered regardless of role. ConfigDb is required for everything.
|
// ConfigDb is admin-only now (per-cluster mesh Phase 4). Only the admin role owns a ConfigDb
|
||||||
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
|
// 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);
|
builder.Services.AddOtOpcUaCluster(builder.Configuration);
|
||||||
|
|
||||||
// Validate LdapOptions unconditionally so ANY role node (admin-only, driver-only, or fused)
|
// 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.
|
// Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration.
|
||||||
builder.Services.AddOtOpcUaSecrets(builder.Configuration);
|
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);
|
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||||
|
|
||||||
// gRPC server plumbing shared by two endpoints: the LocalDb passive sync endpoint (driver-role,
|
// gRPC server plumbing shared by two endpoints: the LocalDb passive sync endpoint (driver-role,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-cluster mesh Phase 4 — ConfigDb is registered iff the node has the <c>admin</c> role.
|
||||||
|
/// A driver-only node (Akka role <c>driver</c>, NOT <c>admin</c>) boots with NO <c>ConfigDb</c>
|
||||||
|
/// connection string at all; its configuration arrives from central via the Phase-3
|
||||||
|
/// <c>ConfigSource:Mode=FetchAndCache</c> path.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// These tests replicate the exact ConfigDb gating decision Program.cs makes —
|
||||||
|
/// <c>if (hasAdmin) services.AddOtOpcUaConfigDb(configuration)</c> — over the real
|
||||||
|
/// <see cref="ServiceCollectionExtensions.AddOtOpcUaConfigDb"/> method, for both role shapes.
|
||||||
|
/// They deliberately do NOT boot the full host: Program.cs reads roles from the
|
||||||
|
/// <c>OTOPCUA_ROLES</c> process env var and starting the host triggers the Akka cluster join
|
||||||
|
/// (see <c>TwoNodeClusterHarness</c> on why <c>WebApplicationFactory<Program></c> is not
|
||||||
|
/// driven here, and <c>LocalDbWiringTests</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverOnlyNoConfigDbBootTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<WebApplication> _apps = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the shared-services graph the way Program.cs gates ConfigDb: it is registered iff
|
||||||
|
/// <paramref name="hasAdmin"/>. A driver-only node passes <c>hasAdmin: false</c> and supplies
|
||||||
|
/// no <c>ConnectionStrings:ConfigDb</c> at all.
|
||||||
|
/// </summary>
|
||||||
|
private IServiceProvider BuildSharedServicesGraph(bool hasAdmin, string? configDbConnectionString)
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
|
||||||
|
if (configDbConnectionString is not null)
|
||||||
|
{
|
||||||
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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<IDbContextFactory<OtOpcUaConfigDbContext>>().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<IDbContextFactory<OtOpcUaConfigDbContext>>().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<InvalidOperationException>(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var app in _apps)
|
||||||
|
((IDisposable)app).Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user