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]