Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthCheckTests.cs
T
Joseph Doherty 5d4853a1f0 refactor(health): adopt the shared active-node check (Health 0.3.0)
OldestNodeActiveHealthCheck existed because the shared ActiveNodeHealthCheck
selected by cluster leadership (review 01 [High]): leadership is address-ordered
and diverges from singleton placement after a restart, and during a partition
both sides compute themselves leader so Traefik served both. Health 0.3.0 makes
the shared check age-based — it is now this host's own rule, promoted — so the
private copy is deleted and central registers the shared type.

ActiveNodeEvaluator, the "THE single definition of active node" this repo already
maintained, now delegates to the shared ClusterActiveNode rather than
re-implementing it. That keeps the delivery gate, the heartbeat IsActive stamp,
the inbound-API gate and the /health/active tier on one rule, and it means the
rule is shared with OtOpcUa, which had independently written a third copy.
Communication takes a ZB.MOM.WW.Health.Akka reference for it; the layering
trade-off is recorded at the PackageReference.

SitePairActiveNodeHealthCheck is deliberately KEPT. It is not a duplicate of the
rule — it is a thin adapter over the site's own IClusterNodeProvider, which
scopes to site-{SiteId} and is itself now backed by ClusterActiveNode. Registering
the shared check directly would have to re-derive the site role and would lose the
property that the tier and singleton placement come from the same provider.
Central stays unscoped: every central member competes for one active slot.

No behaviour change on any node — same rule, one implementation instead of two.

Verified: Host.Tests 439/439, Communication ActiveNode 2/2.
2026-07-24 13:21:58 -04:00

240 lines
11 KiB
C#

using Microsoft.AspNetCore.Builder;
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.LocalDb;
using ZB.MOM.WW.LocalDb.Registration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Host.Health;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Site-node health endpoints. Site nodes previously served no health surface at all — only gRPC on
/// the HTTP/2 listener and <c>/metrics</c> on the HTTP/1.1 one — so the family overview dashboard
/// had no uniform way to probe them. These tests pin the three checks the site now registers and
/// the tier each one lands in.
/// <para>
/// The registration assertions build the REAL container from
/// <see cref="SiteServiceRegistration.Configure"/> rather than a hand-assembled
/// <c>ServiceCollection</c>, for the reason spelled out in <see cref="SiteLocalDbWiringTests"/>:
/// this family's wiring defects have consistently been ones that only a container-built graph
/// catches. Each registration is additionally ACTIVATED through its factory, so a check that is
/// registered but cannot resolve its dependencies fails here instead of at the first live probe.
/// </para>
/// </summary>
public class SiteHealthCheckTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
private readonly string _tempSiteDbPath;
private readonly string _tempTrackingDbPath;
public SiteHealthCheckTests()
{
var stamp = Guid.NewGuid();
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_localdb_{stamp}.db");
_tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_site_{stamp}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_track_{stamp}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Node:GrpcPort"] = "0",
["ScadaBridge:Database:SiteDbPath"] = _tempSiteDbPath,
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["LocalDb:Path"] = _tempDbPath,
});
builder.Services.AddGrpc();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
_host = builder.Build();
}
public void Dispose()
{
(_host as IDisposable)?.Dispose();
foreach (var path in new[] { _tempDbPath, _tempSiteDbPath, _tempTrackingDbPath })
{
try { File.Delete(path); } catch { /* best effort */ }
}
GC.SuppressFinalize(this);
}
private IReadOnlyList<HealthCheckRegistration> Registrations =>
_host.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations.ToList();
[Fact]
public void Site_RegistersExactlyTheThreeSiteChecks()
{
// Exact-set, not "contains": an extra check silently changes what /health/ready means for
// every consumer, and a site node inheriting a central-only probe is precisely the mistake
// this asserts against.
Assert.Equal(
new[] { "active-node", "akka-cluster", "localdb" },
Registrations.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
}
[Theory]
[InlineData("akka-cluster", ZbHealthTags.Ready)]
[InlineData("localdb", ZbHealthTags.Ready)]
[InlineData("active-node", ZbHealthTags.Active)]
public void Site_ChecksCarryTheirIntendedTier(string name, string expectedTag)
{
var registration = Assert.Single(Registrations, r => r.Name == name);
// Exactly one tier tag each: MapZbHealth selects by tag, so a check tagged both Ready and
// Active would put standby-ness into readiness and drop the standby out of readiness too.
Assert.Equal(new[] { expectedTag }, registration.Tags.ToArray());
}
[Theory]
[InlineData("akka-cluster", typeof(AkkaClusterHealthCheck))]
[InlineData("localdb", typeof(SiteLocalDbHealthCheck))]
[InlineData("active-node", typeof(SitePairActiveNodeHealthCheck))]
public void Site_ChecksActivateToTheIntendedType(string name, Type expected)
{
var registration = Assert.Single(Registrations, r => r.Name == name);
// Running the factory is the point: a type-activated check whose constructor dependencies
// are not registered on site would throw here rather than on the first probe in production.
var check = registration.Factory(_host.Services);
Assert.IsType(expected, check);
}
[Fact]
public void Site_ActiveNodeCheck_IsRoleScoped_NotTheUnscopedCentralCheck()
{
// Central registers the shared ActiveNodeHealthCheck UNSCOPED — oldest Up member of the whole
// cluster. On a mesh carrying more than one site's members that computes "oldest" across the
// wrong member set, so a site node could report Active purely for being the oldest member
// overall. A site must answer for its own site-{SiteId} role, which SitePairActiveNodeHealthCheck
// does by delegating to the site's IClusterNodeProvider (itself now backed by the shared
// ClusterActiveNode, so the rule is shared even though the scoping is local).
var check = Assert.Single(Registrations, r => r.Name == "active-node").Factory(_host.Services);
Assert.IsType<SitePairActiveNodeHealthCheck>(check);
Assert.IsNotType<ActiveNodeHealthCheck>(check);
}
[Theory]
[InlineData("database")]
[InlineData("required-singletons")]
public void Site_DoesNotRegisterCentralOnlyChecks(string centralOnlyName)
{
// Positive control first: if the harness ever stopped registering health checks at all,
// every "is absent" assertion below would pass vacuously and this test would be worthless.
Assert.Contains(Registrations, r => r.Name == "akka-cluster");
Assert.DoesNotContain(Registrations, r => r.Name == centralOnlyName);
}
[Fact]
public async Task Site_LocalDbCheck_ReportsHealthyAgainstTheRealSiteDatabase()
{
// Behaviour, not wiring: the probe runs against the consolidated site database this
// composition actually built, so a check that resolves but cannot query fails here.
var check = (SiteLocalDbHealthCheck)Assert.Single(Registrations, r => r.Name == "localdb")
.Factory(_host.Services);
var result = await check.CheckHealthAsync(NewContext("localdb", check));
Assert.Equal(HealthStatus.Healthy, result.Status);
}
[Fact]
public async Task Site_LocalDbCheck_EnrichesWithReplicationStateWithoutFailingOnDefaultOff()
{
// Replication ships default-OFF, so the unreplicated steady state must stay Healthy while
// still surfacing the state as data — enrichment must never become a verdict.
var check = (SiteLocalDbHealthCheck)Assert.Single(Registrations, r => r.Name == "localdb")
.Factory(_host.Services);
var result = await check.CheckHealthAsync(NewContext("localdb", check));
Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.False(Assert.IsType<bool>(result.Data["replicationConnected"]));
Assert.Equal(0L, Assert.IsType<long>(result.Data["replicationOplogBacklog"]));
}
[Fact]
public async Task Site_LocalDbCheck_ReportsUnhealthyWhenTheStoreIsUnreachable()
{
var check = new SiteLocalDbHealthCheck(new ThrowingLocalDb());
var result = await check.CheckHealthAsync(NewContext("localdb", check));
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
[Theory]
[InlineData(true, HealthStatus.Healthy)]
[InlineData(false, HealthStatus.Unhealthy)]
public async Task Site_ActiveNodeCheck_SplitsPrimaryFromStandby(bool selfIsPrimary, HealthStatus expected)
{
// 200 on the primary / 503 on the standby is the same contract central publishes, so a
// consumer reads one active-node meaning fleet-wide.
var check = new SitePairActiveNodeHealthCheck(new StubNodeProvider(selfIsPrimary));
var result = await check.CheckHealthAsync(NewContext("active-node", check));
Assert.Equal(expected, result.Status);
}
private static HealthCheckContext NewContext(string name, IHealthCheck check) => new()
{
Registration = new HealthCheckRegistration(name, check, HealthStatus.Unhealthy, tags: null),
};
private sealed class StubNodeProvider(bool selfIsPrimary) : IClusterNodeProvider
{
public bool SelfIsPrimary { get; } = selfIsPrimary;
public IReadOnlyList<NodeStatus> GetClusterNodes() => [];
}
private sealed class ThrowingLocalDb : ILocalDb
{
public Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default) =>
throw new InvalidOperationException("store unreachable");
public Task<IReadOnlyList<T>> QueryAsync<T>(
string sql,
Func<Microsoft.Data.Sqlite.SqliteDataReader, T> map,
object? parameters = null,
CancellationToken ct = default) =>
throw new InvalidOperationException("store unreachable");
public Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("store unreachable");
public Microsoft.Data.Sqlite.SqliteConnection CreateConnection() =>
throw new InvalidOperationException("store unreachable");
public ReplicatedTable RegisterReplicated(string tableName) =>
throw new InvalidOperationException("store unreachable");
public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables =>
throw new InvalidOperationException("store unreachable");
}
}