From 88268ea3a6980a0c473d18b3acc8740add65d426 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 13:38:03 -0400 Subject: [PATCH] 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 --- .../Health/HealthEndpoints.cs | 21 ++++++++++++---- .../Health/OpcUaEndpointHealthCheck.cs | 6 +++-- .../Health/ActiveTierRegistrationTests.cs | 25 ++++++++++++------- 3 files changed, 36 insertions(+), 16 deletions(-) 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 2f57158f..824223dd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs @@ -31,7 +31,8 @@ public static class HealthEndpoints /// /// /// Whether this node carries the driver role and therefore serves an OPC UA endpoint. When - /// true the joins the ready + active tiers (R03:2.1), reading + /// true the 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 the host registers alongside OtOpcUaServerHostedService. /// Defaults to false so admin-only callers and the existing harnesses register nothing new. /// @@ -40,16 +41,26 @@ public static class HealthEndpoints { var checks = services.AddHealthChecks(); - // A driver node's whole purpose is its OPC UA endpoint, so a failed SDK start must take it out of - // the ready AND active tiers: readiness because it cannot serve, active because a load balancer or - // the fleet must not route to it. Factory-registered so the state singleton is resolved per probe. + // 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()), failureStatus: null, - tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active })); + tags: tags)); } // configdb probe is admin-only (per-cluster mesh Phase 4). A driver-only node gets its config diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/OpcUaEndpointHealthCheck.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/OpcUaEndpointHealthCheck.cs index 8caa69f5..e619d7b3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/OpcUaEndpointHealthCheck.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/OpcUaEndpointHealthCheck.cs @@ -4,14 +4,16 @@ using ZB.MOM.WW.OtOpcUa.Host.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Host.Health; /// -/// Ready + Active tier probe for the node's OPC UA endpoint (R03:2.1): Healthy only while the SDK is +/// Ready-tier probe (plus Active on a driver-only node) for the node's OPC UA endpoint (R03:2.1): Healthy only while the SDK is /// listening. A driver node whose SDK failed to start is otherwise indistinguishable from a healthy /// one over HTTP — the configdb and akka probes are both green — while every OPC UA client gets /// connection-refused. /// /// /// Registered on driver-role nodes only (); an -/// admin-only node serves no OPC UA endpoint and must not be degraded by one it never starts. +/// admin-only node serves no OPC UA endpoint and must not be degraded by one it never starts. On a fused +/// admin+driver node it is Ready-only until ServiceLevel consumes the fact (F-C1), because the Active tier +/// is what Traefik routes the AdminUI on. /// public sealed class OpcUaEndpointHealthCheck : IHealthCheck { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Health/ActiveTierRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Health/ActiveTierRegistrationTests.cs index d880e7b3..45ff262b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Health/ActiveTierRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Health/ActiveTierRegistrationTests.cs @@ -50,31 +50,38 @@ public sealed class ActiveTierRegistrationTests } /// - /// R03:2.1 — the OPC UA endpoint probe is on BOTH tiers of a driver node (it cannot serve, and must not - /// be routed to) and absent from an admin-only node, which starts no endpoint to be degraded by. + /// R03:2.1 + fix round 1 / I6 (coordinator ruling) — the OPC UA endpoint probe's tiers by role: + /// + /// driver-only: Ready + Active (it cannot serve and must not be routed to); + /// fused admin+driver: Ready ONLY, until the ServiceLevel calculator consumes the fact + /// (F-C1) — Traefik routes the AdminUI on /health/active, so a failed SDK on the active + /// central node must not take the AdminUI out of rotation; + /// admin-only: absent (it starts no endpoint to be degraded by). + /// /// [Theory] - [InlineData(true)] - [InlineData(false)] - public void Opcua_endpoint_probe_is_registered_on_ready_and_active_for_driver_nodes_only(bool hasDriver) + [InlineData(false, true, true, true)] + [InlineData(true, true, true, false)] + [InlineData(true, false, false, false)] + public void Opcua_endpoint_probe_tiers_follow_the_node_roles(bool hasAdmin, bool hasDriver, bool ready, bool active) { var registrations = new ServiceCollection() - .AddOtOpcUaHealth(hasAdmin: true, hasDriver: hasDriver) + .AddOtOpcUaHealth(hasAdmin: hasAdmin, hasDriver: hasDriver) .BuildServiceProvider() .GetRequiredService>() .Value.Registrations .Where(r => r.Name == OpcUaEndpointHealthCheck.Name) .ToList(); - if (!hasDriver) + if (!ready && !active) { registrations.ShouldBeEmpty(); return; } var registration = registrations.ShouldHaveSingleItem(); - registration.Tags.ShouldContain(ZbHealthTags.Ready); - registration.Tags.ShouldContain(ZbHealthTags.Active); + registration.Tags.Contains(ZbHealthTags.Ready).ShouldBe(ready); + registration.Tags.Contains(ZbHealthTags.Active).ShouldBe(active); } [Fact]