feat(health): 0.2.0 — optional per-entry data + Akka cluster-view
Phase 0 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md: give the
canonical health JSON a structured channel so the family overview dashboard can
read each Akka cluster's current leader.
- ZbHealthWriter: optional `"data": {...}` per entry, sourced from
HealthReportEntry.Data, emitted only when non-empty. Per-property JsonIgnore
(NOT a global DefaultIgnoreCondition) so `"description": null` still renders —
payloads from data-less checks stay byte-identical to 0.1.0.
- AkkaClusterHealthCheck: BuildClusterData publishes this node's own view —
leader (omitted while unknown), selfAddress, selfRoles (sorted), memberCount,
unreachableCount — on every result path. The startup-safety paths (no
ActorSystem / cluster inaccessible) stay description-only.
- Tests: writer data emit/omit (raw-JSON assert on the omit case), and a real
single-node self-joined cluster via Akka.TestKit.Xunit2 for the data values.
70 tests green (25/39/6).
- Version 0.1.0 -> 0.2.0; 3 packages published to the Gitea feed and
restore-verified from a scratch consumer, which serves data.leader live.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the cluster-view <c>data</c> dictionary that <see cref="AkkaClusterHealthCheck"/> publishes
|
||||
/// from 0.2.0 on (consumed by the family overview dashboard to show each cluster's current leader).
|
||||
/// Uses a real single-node self-joined cluster via <see cref="TestKit"/> — the values come off
|
||||
/// <see cref="Cluster"/>, which cannot be faked, so a formed cluster is the only honest fixture.
|
||||
/// </summary>
|
||||
public sealed class AkkaClusterDataTests : TestKit
|
||||
{
|
||||
private const string ClusterConfig = """
|
||||
akka {
|
||||
actor.provider = cluster
|
||||
remote.dot-netty.tcp {
|
||||
hostname = "127.0.0.1"
|
||||
port = 0
|
||||
}
|
||||
cluster {
|
||||
roles = ["driver", "admin"]
|
||||
# Keep the single-node self-join deterministic and fast in-test.
|
||||
min-nr-of-members = 1
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private static readonly TimeSpan FormationTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
public AkkaClusterDataTests()
|
||||
: base(ClusterConfig, nameof(AkkaClusterDataTests))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Self-joins the test node and waits until it is Up with a converged leader.</summary>
|
||||
private Cluster FormSingleNodeCluster()
|
||||
{
|
||||
var cluster = Cluster.Get(Sys);
|
||||
cluster.Join(cluster.SelfAddress);
|
||||
AwaitCondition(
|
||||
() => cluster.SelfMember.Status == MemberStatus.Up && cluster.State.Leader is not null,
|
||||
FormationTimeout);
|
||||
return cluster;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildClusterData_SingleNodeCluster_ReportsSelfAsLeader()
|
||||
{
|
||||
var cluster = FormSingleNodeCluster();
|
||||
|
||||
var data = AkkaClusterHealthCheck.BuildClusterData(cluster);
|
||||
|
||||
Assert.Equal(cluster.SelfAddress.ToString(), Assert.IsType<string>(data["leader"]));
|
||||
Assert.Equal(cluster.SelfAddress.ToString(), Assert.IsType<string>(data["selfAddress"]));
|
||||
Assert.Equal(1, Assert.IsType<int>(data["memberCount"]));
|
||||
Assert.Equal(0, Assert.IsType<int>(data["unreachableCount"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildClusterData_RolesRoundTripSorted()
|
||||
{
|
||||
var cluster = FormSingleNodeCluster();
|
||||
|
||||
var roles = Assert.IsType<string[]>(AkkaClusterHealthCheck.BuildClusterData(cluster)["selfRoles"]);
|
||||
|
||||
// Sorted, so the payload is stable across restarts; the configured roles survive verbatim.
|
||||
Assert.Equal(new[] { "admin", "driver" }, roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealth_FormedCluster_HealthyResultCarriesClusterData()
|
||||
{
|
||||
var cluster = FormSingleNodeCluster();
|
||||
var provider = new ServiceCollection().AddSingleton(Sys).BuildServiceProvider();
|
||||
var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal(cluster.SelfAddress.ToString(), result.Data["leader"]);
|
||||
Assert.Equal(1, result.Data["memberCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealth_NoActorSystem_CarriesNoData()
|
||||
{
|
||||
// The startup-safety paths stay description-only: an empty data dictionary is what makes the
|
||||
// writer omit the "data" key entirely, so a not-yet-started node's payload is unchanged.
|
||||
var provider = new ServiceCollection().BuildServiceProvider();
|
||||
var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
Assert.Empty(result.Data);
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(IHealthCheck check) => new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration("akka-cluster", check, HealthStatus.Unhealthy, tags: null),
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,8 @@ public sealed class AkkaClusterStatusPolicyTests
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
// No cluster to describe → no data, so the emitted payload stays the pre-0.2.0 shape.
|
||||
Assert.Empty(result.Data);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
|
||||
@@ -22,8 +22,11 @@ public sealed class ResponseWriterTests
|
||||
{
|
||||
private readonly HealthCheckResult _result;
|
||||
|
||||
public StubHealthCheck(HealthStatus status, string? description = null) =>
|
||||
_result = new HealthCheckResult(status, description);
|
||||
public StubHealthCheck(
|
||||
HealthStatus status,
|
||||
string? description = null,
|
||||
IReadOnlyDictionary<string, object>? data = null) =>
|
||||
_result = new HealthCheckResult(status, description, exception: null, data: data);
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
@@ -31,12 +34,14 @@ public sealed class ResponseWriterTests
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> GetReadyAsync(
|
||||
HealthStatus status, string? description = "db reachable")
|
||||
HealthStatus status,
|
||||
string? description = "db reachable",
|
||||
IReadOnlyDictionary<string, object>? data = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("db", new StubHealthCheck(status, description), tags: new[] { ZbHealthTags.Ready });
|
||||
.AddCheck("db", new StubHealthCheck(status, description, data), tags: new[] { ZbHealthTags.Ready });
|
||||
|
||||
await using var app = builder.Build();
|
||||
app.MapZbHealth();
|
||||
@@ -84,6 +89,51 @@ public sealed class ResponseWriterTests
|
||||
Assert.Equal(JsonValueKind.Null, description.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_EntryWithData_EmitsDataObject_WithVerbatimKeys()
|
||||
{
|
||||
// The envelope properties are camelCased by the naming policy, but a check's own data keys are
|
||||
// dictionary keys and must survive verbatim — the dashboard reads them by exact name
|
||||
// (entries["akka-cluster"].data.leader), so a naming policy applied here would silently break
|
||||
// the contract. Values may be any JSON-friendly type (string / int / string[]).
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["leader"] = "akka.tcp://sys@node-1:4053",
|
||||
["memberCount"] = 2,
|
||||
["selfRoles"] = new[] { "driver", "site-a" },
|
||||
};
|
||||
|
||||
var response = await GetReadyAsync(HealthStatus.Healthy, data: data);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var entryData = doc.RootElement.GetProperty("entries").GetProperty("db").GetProperty("data");
|
||||
|
||||
Assert.Equal("akka.tcp://sys@node-1:4053", entryData.GetProperty("leader").GetString());
|
||||
Assert.Equal(2, entryData.GetProperty("memberCount").GetInt32());
|
||||
Assert.Equal(
|
||||
new[] { "driver", "site-a" },
|
||||
entryData.GetProperty("selfRoles").EnumerateArray().Select(static e => e.GetString()).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_EntryWithoutData_OmitsDataKeyEntirely()
|
||||
{
|
||||
// The 0.2.0 non-breaking invariant: a check that publishes no data must produce exactly the
|
||||
// pre-0.2.0 body. Asserted on the raw JSON string so a `"data": null` regression (which
|
||||
// TryGetProperty would still accept as "present") fails here.
|
||||
var response = await GetReadyAsync(HealthStatus.Healthy);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.DoesNotContain("\"data\"", body, StringComparison.Ordinal);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var db = doc.RootElement.GetProperty("entries").GetProperty("db");
|
||||
Assert.False(db.TryGetProperty("data", out _), "data key must be absent when the check set none");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_Degraded_Returns200_WithDegradedStatus()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user