Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs
T
Joseph Doherty 88268ea3a6 fix(host): OPC UA endpoint probe is Ready-only on a fused admin+driver node
Coordinator ruling (I6): until ServiceLevel consumes IOpcUaEndpointState (F-C1), the probe must not sit on
the Active tier of a fused node, because Traefik routes the AdminUI on /health/active. Driver-only nodes keep
it on Ready + Active; admin-only nodes do not register it. Pinned per role.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:38:03 -04:00

138 lines
7.8 KiB
C#

using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.Health.EntityFrameworkCore;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
public static class HealthEndpoints
{
/// <summary>
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
/// ready+active; cluster-primary 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>
/// <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>
/// <param name="hasDriver">
/// Whether this node carries the <c>driver</c> role and therefore serves an OPC UA endpoint. When
/// <c>true</c> the <see cref="OpcUaEndpointHealthCheck"/> joins the ready tier, and the active tier on a
/// driver-ONLY node (R03:2.1; on a fused node the active tier routes the AdminUI — see the body), reading
/// the <see cref="IOpcUaEndpointState"/> the host registers alongside <c>OtOpcUaServerHostedService</c>.
/// Defaults to <c>false</c> so admin-only callers and the existing harnesses register nothing new.
/// </param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services, bool hasAdmin = true, bool hasDriver = false)
{
var checks = services.AddHealthChecks();
// A failed SDK start takes the node out of the READY tier on every driver node: it cannot serve.
// The ACTIVE tier depends on the roles (coordinator ruling, fix round 1 / I6):
// - driver-only: Active too — a site node's whole purpose is its OPC UA endpoint, so nothing may
// route to it;
// - fused admin+driver: NOT Active, until the ServiceLevel calculator consumes IOpcUaEndpointState
// (follow-up F-C1). Traefik routes the AdminUI on /health/active, so putting the probe there would
// take the operator's only console out of rotation precisely when they need it to diagnose the
// failed endpoint, while the OPC UA failover (ServiceLevel) still would not move. Once F-C1 lands
// the peer takes Primary and the active tier follows it on its own.
// Factory-registered so the state singleton is resolved per probe.
if (hasDriver)
{
var tags = hasAdmin
? new[] { ZbHealthTags.Ready }
: new[] { ZbHealthTags.Ready, ZbHealthTags.Active };
checks.Add(new HealthCheckRegistration(
OpcUaEndpointHealthCheck.Name,
sp => new OpcUaEndpointHealthCheck(sp.GetRequiredService<IOpcUaEndpointState>()),
failureStatus: null,
tags: tags));
}
// 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",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
args: new DatabaseHealthCheckOptions<OtOpcUaConfigDbContext>
{
ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct),
});
}
checks
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
"akka",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
args: AkkaClusterStatusPolicy.OtOpcUaCompat)
// "Is this node in charge of its mesh?" — Healthy on the one node that owns the active
// work for its Cluster, Unhealthy (503) on its partner, so Traefik pins the AdminUI to the
// node actually hosting the cluster singletons.
//
// Role preference admin-then-driver: a fused central node answers for `admin` (the role
// the singletons and the AdminUI are pinned to), while a driver-only site node answers for
// `driver` — which is exactly RedundancyStateActor.SelectDriverPrimary, the election behind
// IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Both now route through the
// shared ClusterActiveNode, so the tier and the ServiceLevel cannot disagree.
//
// Scoping is per-mesh for free: after per-cluster mesh Phase 6 a node's ClusterState
// contains only its own application Cluster, so "oldest Up member" is already "oldest Up
// member of this Cluster" — exactly one node answers 200 per Cluster, by construction.
//
// Before Health 0.3.0 this was ActiveNodeHealthCheck(role: "admin"), which answered a
// different question and got it wrong twice: it returned Healthy for any node lacking the
// admin role, so every driver-only site node called itself active, and it selected by
// RoleLeader (lowest address) rather than by age, which is not where Akka places
// singletons. See lmxopcua#494.
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
"cluster-primary",
failureStatus: null,
tags: new[] { ZbHealthTags.Active },
args: new ActiveNodeHealthCheckOptions
{
RolePreference = new[] { RoleParser.Admin, RoleParser.Driver },
})
// 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(
sp.GetService<ISyncStatus>(),
sp.GetRequiredService<IOptions<ReplicationOptions>>(),
LocalDbRegistration.SyncListenPort(sp.GetRequiredService<IConfiguration>())),
failureStatus: null,
tags: new[] { ZbHealthTags.Active }));
return services;
}
/// <summary>Maps the OtOpcUa health check endpoints to the route builder.</summary>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The same endpoint route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
{
app.MapZbHealth();
return app;
}
}