88268ea3a6
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
138 lines
7.8 KiB
C#
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;
|
|
}
|
|
}
|