feat: adopt shared ZB.MOM.WW.Health probes; add /healthz; canonical writer
This commit is contained in:
@@ -1,45 +0,0 @@
|
|||||||
using Akka.Cluster;
|
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Health check that returns healthy only if this node is the active (leader) node
|
|
||||||
/// in the Akka.NET cluster. Used by Traefik to route traffic to the active node.
|
|
||||||
/// </summary>
|
|
||||||
public class ActiveNodeHealthCheck : IHealthCheck
|
|
||||||
{
|
|
||||||
private readonly AkkaHostedService _akkaService;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new <see cref="ActiveNodeHealthCheck"/> with the given Akka hosted service.</summary>
|
|
||||||
/// <param name="akkaService">The Akka hosted service providing access to the actor system and cluster state.</param>
|
|
||||||
public ActiveNodeHealthCheck(AkkaHostedService akkaService)
|
|
||||||
{
|
|
||||||
_akkaService = akkaService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Returns healthy if this node is the cluster leader (active node); otherwise returns unhealthy.</summary>
|
|
||||||
/// <param name="context">Health check context providing registration details.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
public Task<HealthCheckResult> CheckHealthAsync(
|
|
||||||
HealthCheckContext context,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var system = _akkaService.ActorSystem;
|
|
||||||
if (system == null)
|
|
||||||
return Task.FromResult(HealthCheckResult.Unhealthy("ActorSystem not yet available."));
|
|
||||||
|
|
||||||
var cluster = Cluster.Get(system);
|
|
||||||
var self = cluster.SelfMember;
|
|
||||||
|
|
||||||
if (self.Status != MemberStatus.Up)
|
|
||||||
return Task.FromResult(HealthCheckResult.Unhealthy($"Node not Up (status: {self.Status})."));
|
|
||||||
|
|
||||||
var leader = cluster.State.Leader;
|
|
||||||
if (leader != null && leader == self.Address)
|
|
||||||
return Task.FromResult(HealthCheckResult.Healthy("Active node (cluster leader)."));
|
|
||||||
|
|
||||||
return Task.FromResult(HealthCheckResult.Unhealthy("Standby node (not cluster leader)."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using Akka.Cluster;
|
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Health check that verifies this node is an active member of the Akka.NET cluster.
|
|
||||||
/// Returns healthy only if the node's self-member status is Up or Joining.
|
|
||||||
/// </summary>
|
|
||||||
public class AkkaClusterHealthCheck : IHealthCheck
|
|
||||||
{
|
|
||||||
private readonly AkkaHostedService _akkaService;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes the health check with the Akka hosted service.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="akkaService">The hosted service providing access to the Akka actor system.</param>
|
|
||||||
public AkkaClusterHealthCheck(AkkaHostedService akkaService)
|
|
||||||
{
|
|
||||||
_akkaService = akkaService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks that this node is an active member of the Akka.NET cluster.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="context">Health check context.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
public Task<HealthCheckResult> CheckHealthAsync(
|
|
||||||
HealthCheckContext context,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var system = _akkaService.ActorSystem;
|
|
||||||
if (system == null)
|
|
||||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
|
||||||
|
|
||||||
var cluster = Cluster.Get(system);
|
|
||||||
var status = cluster.SelfMember.Status;
|
|
||||||
|
|
||||||
var result = status switch
|
|
||||||
{
|
|
||||||
MemberStatus.Up or MemberStatus.Joining =>
|
|
||||||
HealthCheckResult.Healthy($"Akka cluster member status: {status}"),
|
|
||||||
MemberStatus.Leaving or MemberStatus.Exiting =>
|
|
||||||
HealthCheckResult.Degraded($"Akka cluster member status: {status}"),
|
|
||||||
_ =>
|
|
||||||
HealthCheckResult.Unhealthy($"Akka cluster member status: {status}")
|
|
||||||
};
|
|
||||||
|
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Health check that verifies database connectivity for Central nodes.
|
|
||||||
/// </summary>
|
|
||||||
public class DatabaseHealthCheck : IHealthCheck
|
|
||||||
{
|
|
||||||
private readonly ScadaBridgeDbContext _dbContext;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new <see cref="DatabaseHealthCheck"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dbContext">The EF Core database context used to test connectivity.</param>
|
|
||||||
public DatabaseHealthCheck(ScadaBridgeDbContext dbContext)
|
|
||||||
{
|
|
||||||
_dbContext = dbContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks database connectivity by attempting to open a connection.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="context">Health check context providing failure status information.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token for the check.</param>
|
|
||||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
||||||
HealthCheckContext context,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
|
|
||||||
return canConnect
|
|
||||||
? HealthCheckResult.Healthy("Database connection is available.")
|
|
||||||
: HealthCheckResult.Unhealthy("Database connection failed.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return HealthCheckResult.Unhealthy("Database connection failed.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using HealthChecks.UI.Client;
|
using ZB.MOM.WW.Health;
|
||||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
using ZB.MOM.WW.Health.Akka;
|
||||||
|
using ZB.MOM.WW.Health.EntityFrameworkCore;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog;
|
using ZB.MOM.WW.ScadaBridge.AuditLog;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI;
|
using ZB.MOM.WW.ScadaBridge.CentralUI;
|
||||||
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||||
@@ -110,11 +111,25 @@ try
|
|||||||
?? throw new InvalidOperationException("ScadaBridge:Database:ConfigurationDb connection string is required for Central role.");
|
?? throw new InvalidOperationException("ScadaBridge:Database:ConfigurationDb connection string is required for Central role.");
|
||||||
builder.Services.AddConfigurationDatabase(configDbConnectionString);
|
builder.Services.AddConfigurationDatabase(configDbConnectionString);
|
||||||
|
|
||||||
// WP-12: Health checks for readiness gating
|
// WP-12: Health checks for readiness gating — shared ZB.MOM.WW.Health probes.
|
||||||
|
// Check names and the ready/active tier split are preserved: database + akka-cluster
|
||||||
|
// carry the Ready tag (/health/ready), active-node carries the Active tag (/health/active).
|
||||||
|
// The Akka checks resolve ActorSystem from DI via the transient bridge registered below;
|
||||||
|
// the DatabaseHealthCheck<TContext> resolves a scoped ScadaBridgeDbContext (no factory).
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddCheck<DatabaseHealthCheck>("database")
|
.AddTypeActivatedCheck<DatabaseHealthCheck<ScadaBridgeDbContext>>(
|
||||||
.AddCheck<AkkaClusterHealthCheck>("akka-cluster")
|
"database",
|
||||||
.AddCheck<ActiveNodeHealthCheck>("active-node");
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Ready })
|
||||||
|
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
|
||||||
|
"akka-cluster",
|
||||||
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Ready },
|
||||||
|
args: AkkaClusterStatusPolicy.Default)
|
||||||
|
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
|
||||||
|
"active-node",
|
||||||
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Active });
|
||||||
|
|
||||||
// WP-13: Akka.NET bootstrap via hosted service
|
// WP-13: Akka.NET bootstrap via hosted service
|
||||||
builder.Services.AddSingleton<AkkaHostedService>();
|
builder.Services.AddSingleton<AkkaHostedService>();
|
||||||
@@ -221,23 +236,17 @@ try
|
|||||||
&& HttpMethods.IsPost(ctx.Request.Method),
|
&& HttpMethods.IsPost(ctx.Request.Method),
|
||||||
branch => branch.UseAuditWriteMiddleware());
|
branch => branch.UseAuditWriteMiddleware());
|
||||||
|
|
||||||
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
|
// WP-12: Map the canonical three-tier health endpoints in one call:
|
||||||
// REQ-HOST-4a defines readiness as cluster membership + DB connectivity,
|
// /health/ready — Ready-tagged checks (database + akka-cluster). REQ-HOST-4a defines
|
||||||
// explicitly NOT cluster leadership. The leader-only "active-node" check is
|
// readiness as cluster membership + DB connectivity, explicitly NOT
|
||||||
// excluded here so a fully operational standby central node reports ready;
|
// cluster leadership, so the leader-only active-node check is excluded
|
||||||
// leadership is reported separately on /health/active.
|
// (a fully operational standby central node still reports ready).
|
||||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
// /health/active — Active-tagged check (active-node); returns 200 only on the cluster
|
||||||
{
|
// leader; used by Traefik for routing.
|
||||||
Predicate = check => check.Name != "active-node",
|
// /healthz — bare process liveness; runs no checks (always 200 while the process
|
||||||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
// is up). New tier added by adopting the shared library.
|
||||||
});
|
// All three are anonymous and use the canonical ZbHealthWriter JSON output.
|
||||||
|
app.MapZbHealth();
|
||||||
// Active node endpoint — returns 200 only on the cluster leader; used by Traefik for routing
|
|
||||||
app.MapHealthChecks("/health/active", new HealthCheckOptions
|
|
||||||
{
|
|
||||||
Predicate = check => check.Name == "active-node",
|
|
||||||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapCentralUI<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
|
app.MapCentralUI<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
using System.Linq;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Health;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-12: Tests for /health/ready and /health/active endpoints.
|
/// WP-12: Tests for the three-tier health endpoints after adopting the shared
|
||||||
|
/// ZB.MOM.WW.Health probes. Verifies that /health/ready, /health/active and the new
|
||||||
|
/// /healthz tier are mapped, and that the readiness/active tier split is now carried by
|
||||||
|
/// the canonical <see cref="ZbHealthTags"/> (Ready for database + akka-cluster, Active for
|
||||||
|
/// active-node) rather than by check-name predicates. These are pure route/tag assertions
|
||||||
|
/// — they require no database, LDAP, or formed Akka cluster.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class HealthCheckTests : IDisposable
|
public class HealthCheckTests : IDisposable
|
||||||
{
|
{
|
||||||
@@ -25,41 +34,49 @@ public class HealthCheckTests : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WebApplicationFactory<Program> CreateCentralFactory()
|
||||||
|
{
|
||||||
|
var factory = new WebApplicationFactory<Program>()
|
||||||
|
.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((context, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||||
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||||
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||||
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||||
|
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||||
|
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||||
|
});
|
||||||
|
_disposables.Add(factory);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<HealthCheckRegistration> Registrations(WebApplicationFactory<Program> factory) =>
|
||||||
|
factory.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HealthReady_Endpoint_ReturnsResponse()
|
public async Task HealthReady_Endpoint_IsMapped()
|
||||||
{
|
{
|
||||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
var factory = CreateCentralFactory();
|
||||||
var factory = new WebApplicationFactory<Program>()
|
|
||||||
.WithWebHostBuilder(builder =>
|
|
||||||
{
|
|
||||||
builder.ConfigureAppConfiguration((context, config) =>
|
|
||||||
{
|
|
||||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
|
||||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
||||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
||||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
||||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
|
||||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
|
||||||
});
|
|
||||||
_disposables.Add(factory);
|
|
||||||
|
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
_disposables.Add(client);
|
_disposables.Add(client);
|
||||||
|
|
||||||
var response = await client.GetAsync("/health/ready");
|
var response = await client.GetAsync("/health/ready");
|
||||||
|
|
||||||
// The endpoint exists and returns a status code.
|
// The endpoint exists and returns a status code. With test infrastructure
|
||||||
// With test infrastructure (no real DB), the database check may fail,
|
// (no real DB / cluster) the readiness checks may report Unhealthy, so we
|
||||||
// so we accept either 200 (Healthy) or 503 (Unhealthy).
|
// accept either 200 (Healthy/Degraded) or 503 (Unhealthy) — never 404.
|
||||||
|
Assert.NotEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode);
|
||||||
Assert.True(
|
Assert.True(
|
||||||
response.StatusCode == System.Net.HttpStatusCode.OK ||
|
response.StatusCode == System.Net.HttpStatusCode.OK ||
|
||||||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
|
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
|
||||||
@@ -72,39 +89,19 @@ public class HealthCheckTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HealthActive_Endpoint_ReturnsResponse()
|
public async Task HealthActive_Endpoint_IsMapped()
|
||||||
{
|
{
|
||||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
var factory = CreateCentralFactory();
|
||||||
var factory = new WebApplicationFactory<Program>()
|
|
||||||
.WithWebHostBuilder(builder =>
|
|
||||||
{
|
|
||||||
builder.ConfigureAppConfiguration((context, config) =>
|
|
||||||
{
|
|
||||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
|
||||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
||||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
||||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
||||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
|
||||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
|
||||||
});
|
|
||||||
_disposables.Add(factory);
|
|
||||||
|
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
_disposables.Add(client);
|
_disposables.Add(client);
|
||||||
|
|
||||||
var response = await client.GetAsync("/health/active");
|
var response = await client.GetAsync("/health/active");
|
||||||
|
|
||||||
// In test mode, the ActorSystem may not be fully available,
|
Assert.NotEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode);
|
||||||
// so the active-node check returns 503 (Unhealthy).
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
response.StatusCode == System.Net.HttpStatusCode.OK ||
|
response.StatusCode == System.Net.HttpStatusCode.OK ||
|
||||||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
|
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
|
||||||
@@ -117,46 +114,21 @@ public class HealthCheckTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HealthReady_Endpoint_ExcludesActiveNodeCheck()
|
public async Task Healthz_LivenessEndpoint_IsMappedAndReturns200()
|
||||||
{
|
{
|
||||||
// Host-001 regression: /health/ready must reflect cluster membership + DB
|
// New tier added by adopting the shared library: /healthz runs no checks, so it
|
||||||
// connectivity only (REQ-HOST-4a), NOT cluster leadership. The leader-only
|
// returns 200 as long as the process is up — independent of DB / cluster state.
|
||||||
// "active-node" check belongs solely to /health/active. If /health/ready
|
|
||||||
// included "active-node", a fully operational standby central node would
|
|
||||||
// permanently report 503, breaking load-balancer failover readiness.
|
|
||||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
var factory = CreateCentralFactory();
|
||||||
var factory = new WebApplicationFactory<Program>()
|
|
||||||
.WithWebHostBuilder(builder =>
|
|
||||||
{
|
|
||||||
builder.ConfigureAppConfiguration((context, config) =>
|
|
||||||
{
|
|
||||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
|
||||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
||||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
||||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
||||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
|
||||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
|
||||||
});
|
|
||||||
_disposables.Add(factory);
|
|
||||||
|
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
_disposables.Add(client);
|
_disposables.Add(client);
|
||||||
|
|
||||||
var response = await client.GetAsync("/health/ready");
|
var response = await client.GetAsync("/healthz");
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
// The readiness body lists each executed check by name in its entries map.
|
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
|
||||||
// The leader-only "active-node" check must not be among them.
|
|
||||||
Assert.DoesNotContain("active-node", body);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -165,43 +137,54 @@ public class HealthCheckTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ActiveNodeHealthCheck_SystemNotStarted_ReturnsUnhealthy()
|
public void ReadyTier_Carries_Database_And_AkkaCluster()
|
||||||
{
|
{
|
||||||
// AkkaHostedService before StartAsync has ActorSystem == null.
|
// Host-001 regression guard: readiness reflects cluster membership + DB connectivity
|
||||||
// The integration test (HealthActive_Endpoint_ReturnsResponse) validates the full
|
// only (REQ-HOST-4a), NOT cluster leadership. The split is now carried by the Ready tag
|
||||||
// endpoint wiring. This test validates the null-system path via WebApplicationFactory
|
// rather than a check-name predicate: database + akka-cluster are Ready-tagged, and the
|
||||||
// where the ActorSystem may not be available.
|
// leader-only active-node check is NOT — so a fully operational standby central node
|
||||||
|
// still reports ready on /health/ready.
|
||||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
var factory = new WebApplicationFactory<Program>()
|
var factory = CreateCentralFactory();
|
||||||
.WithWebHostBuilder(builder =>
|
|
||||||
{
|
|
||||||
builder.ConfigureAppConfiguration((context, config) =>
|
|
||||||
{
|
|
||||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
|
||||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
||||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
||||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
|
||||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
|
||||||
});
|
|
||||||
_disposables.Add(factory);
|
|
||||||
|
|
||||||
var client = factory.CreateClient();
|
var registrations = Registrations(factory).ToDictionary(r => r.Name);
|
||||||
_disposables.Add(client);
|
|
||||||
|
|
||||||
var response = await client.GetAsync("/health/active");
|
Assert.True(registrations.ContainsKey("database"), "Expected a 'database' health check.");
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
Assert.True(registrations.ContainsKey("akka-cluster"), "Expected an 'akka-cluster' health check.");
|
||||||
|
|
||||||
// Active-node check returns 503 when ActorSystem is not yet available or not leader
|
Assert.Contains(ZbHealthTags.Ready, registrations["database"].Tags);
|
||||||
Assert.Equal(System.Net.HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
Assert.Contains(ZbHealthTags.Ready, registrations["akka-cluster"].Tags);
|
||||||
Assert.Contains("active-node", body);
|
|
||||||
|
// The leader-only active-node check must NOT be on the readiness tier.
|
||||||
|
Assert.DoesNotContain(ZbHealthTags.Ready, registrations["active-node"].Tags);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveTier_Carries_Only_ActiveNode()
|
||||||
|
{
|
||||||
|
// The active-node leader check carries the Active tag (→ /health/active); the readiness
|
||||||
|
// checks do not, so /health/active reports leadership alone.
|
||||||
|
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
var factory = CreateCentralFactory();
|
||||||
|
|
||||||
|
var registrations = Registrations(factory).ToDictionary(r => r.Name);
|
||||||
|
|
||||||
|
Assert.True(registrations.ContainsKey("active-node"), "Expected an 'active-node' health check.");
|
||||||
|
Assert.Contains(ZbHealthTags.Active, registrations["active-node"].Tags);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(ZbHealthTags.Active, registrations["database"].Tags);
|
||||||
|
Assert.DoesNotContain(ZbHealthTags.Active, registrations["akka-cluster"].Tags);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user