diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyHealthCheck.cs new file mode 100644 index 0000000..0c577d8 --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyHealthCheck.cs @@ -0,0 +1,80 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ZB.MOM.WW.Health; + +/// +/// Health check that verifies a downstream gRPC dependency is reachable over its +/// . +/// +/// +/// +/// The probe is injectable via ; the default drives the +/// channel to a connected state with . The result is +/// when the probe returns true, and +/// when it returns false, throws an +/// , or times out / is cancelled within +/// . +/// +/// +/// Recommended registration tags: and +/// — a missing downstream gRPC dependency makes the node both +/// not-ready and not-able-to-act. The registrant applies the tags. +/// +/// +public sealed class GrpcDependencyHealthCheck : IHealthCheck +{ + private readonly GrpcChannel _channel; + private readonly GrpcDependencyOptions _options; + + /// Initializes a new . + /// The gRPC channel to the downstream dependency. + /// + /// Probe, dependency name, and timeout. When null, defaults are used (the default probe is + /// with a 5 s timeout). + /// + public GrpcDependencyHealthCheck(GrpcChannel channel, GrpcDependencyOptions? options = null) + { + _channel = channel ?? throw new ArgumentNullException(nameof(channel)); + _options = options ?? new GrpcDependencyOptions(); + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var name = _options.DependencyName ?? "gRPC dependency"; + var probe = _options.Probe ?? DefaultProbeAsync; + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_options.Timeout); + + try + { + var reachable = await probe(_channel, timeoutCts.Token).ConfigureAwait(false); + return reachable + ? HealthCheckResult.Healthy($"{name} is reachable.") + : HealthCheckResult.Unhealthy($"{name} is unreachable."); + } + catch (RpcException ex) + { + return HealthCheckResult.Unhealthy($"{name} probe failed: {ex.Status.StatusCode}.", ex); + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + return HealthCheckResult.Unhealthy($"{name} probe timed out after {_options.Timeout}.", ex); + } + } + + /// + /// Default probe: connects the channel and reports reachability. Returns true once the + /// channel reaches a connected state; surfaces failures as a thrown exception (handled by the caller). + /// + private static async Task DefaultProbeAsync(GrpcChannel channel, CancellationToken cancellationToken) + { + await channel.ConnectAsync(cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyOptions.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyOptions.cs new file mode 100644 index 0000000..bd42812 --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/GrpcDependencyOptions.cs @@ -0,0 +1,26 @@ +using Grpc.Net.Client; + +namespace ZB.MOM.WW.Health; + +/// +/// Options for . +/// +public sealed class GrpcDependencyOptions +{ + /// + /// The reachability probe. Returns true when the dependency is reachable, false + /// otherwise. When null the default probe is used: , + /// which drives the channel to the state (or + /// throws / cancels on failure). Override to perform a richer probe, e.g. a + /// grpc.health.v1.Health/Check RPC returning SERVING. + /// + public Func>? Probe { get; set; } + + /// + /// Human-readable name of the dependency, surfaced in the HealthCheckResult description. + /// + public string? DependencyName { get; set; } + + /// Maximum time the probe may take before it is treated as unreachable. Default 5 s. + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5); +} diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/GrpcDependencyHealthCheckTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/GrpcDependencyHealthCheckTests.cs new file mode 100644 index 0000000..d1dd117 --- /dev/null +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/GrpcDependencyHealthCheckTests.cs @@ -0,0 +1,72 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.Health; + +namespace ZB.MOM.WW.Health.Tests; + +/// +/// Verifies via an injected probe (no live gRPC server): +/// probe-true → Healthy, probe-false → Unhealthy, and an from the probe +/// → Unhealthy. The channel is constructed but never dialled because the probe is stubbed. +/// +public sealed class GrpcDependencyHealthCheckTests +{ + private static readonly GrpcChannel Channel = GrpcChannel.ForAddress("http://localhost"); + + private static async Task RunAsync(GrpcDependencyOptions options) + { + var check = new GrpcDependencyHealthCheck(Channel, options); + var context = new HealthCheckContext + { + Registration = new HealthCheckRegistration("grpc-dep", check, HealthStatus.Unhealthy, tags: null), + }; + return await check.CheckHealthAsync(context, CancellationToken.None); + } + + [Fact] + public async Task ProbeReturnsTrue_Healthy() + { + var result = await RunAsync(new GrpcDependencyOptions + { + Probe = static (_, _) => Task.FromResult(true), + }); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task ProbeReturnsFalse_Unhealthy() + { + var result = await RunAsync(new GrpcDependencyOptions + { + Probe = static (_, _) => Task.FromResult(false), + }); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + [Fact] + public async Task ProbeThrowsRpcException_Unhealthy() + { + var result = await RunAsync(new GrpcDependencyOptions + { + Probe = static (_, _) => throw new RpcException(new Status(StatusCode.Unavailable, "down")), + }); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + [Fact] + public async Task DependencyName_AppearsInDescription() + { + var result = await RunAsync(new GrpcDependencyOptions + { + DependencyName = "mxaccessgw worker", + Probe = static (_, _) => Task.FromResult(false), + }); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("mxaccessgw worker", result.Description); + } +}