feat: add database and LDAP health checks with JSON response formatting
Implement health checks for SQL Server, Oracle databases (JDE, CMS, GIW), and LDAP servers to enable comprehensive system monitoring via the /health endpoint.
This commit is contained in:
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.OpenApi.Models;
|
||||
@@ -117,9 +118,35 @@ public static class ApiDependencyInjection
|
||||
// Health check endpoint - no authentication required
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false
|
||||
AllowCachingResponses = false,
|
||||
ResponseWriter = WriteHealthCheckResponse
|
||||
}).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes detailed JSON response for health check results.
|
||||
/// </summary>
|
||||
private static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var result = new
|
||||
{
|
||||
status = report.Status.ToString(),
|
||||
totalDuration = report.TotalDuration.TotalMilliseconds,
|
||||
checks = report.Entries.Select(e => new
|
||||
{
|
||||
name = e.Key,
|
||||
status = e.Value.Status.ToString(),
|
||||
description = e.Value.Description,
|
||||
duration = e.Value.Duration.TotalMilliseconds,
|
||||
data = e.Value.Data,
|
||||
exception = e.Value.Exception?.Message
|
||||
})
|
||||
};
|
||||
|
||||
await context.Response.WriteAsJsonAsync(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.DataAccess;
|
||||
using JdeScoping.DataAccess.HealthChecks;
|
||||
using JdeScoping.DataAccess.Options;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using JdeScoping.DataAccess.QueryBuilders;
|
||||
@@ -55,6 +56,10 @@ public static class DataAccessDependencyInjection
|
||||
// Register manual sync request service (scoped - per request lifetime)
|
||||
services.AddScoped<IManualSyncRequestService, ManualSyncRequestService>();
|
||||
|
||||
// Register health check
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<DatabaseHealthCheck>("database", tags: ["db", "ready"]);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Diagnostics;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace JdeScoping.DataAccess.HealthChecks;
|
||||
|
||||
/// <summary>
|
||||
/// Health check for database connectivity to all data sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Checks connectivity to:
|
||||
/// - LotFinder (SQL Server) - Critical, causes Unhealthy if unavailable
|
||||
/// - JDE, CMS, GIW (Oracle) - Important but not critical, causes Degraded if unavailable
|
||||
/// </remarks>
|
||||
public class DatabaseHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly long DegradedThresholdMs = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseHealthCheck"/> class.
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">The database connection factory.</param>
|
||||
public DatabaseHealthCheck(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var unhealthyDbs = new List<string>();
|
||||
var degradedDbs = new List<string>();
|
||||
|
||||
// Check LotFinder (SQL Server) - Critical
|
||||
await CheckLotFinderAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
|
||||
|
||||
// Check Oracle databases - Important but not critical
|
||||
await CheckJdeAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
|
||||
await CheckCmsAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
|
||||
await CheckGiwAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
|
||||
|
||||
// Determine overall status
|
||||
if (unhealthyDbs.Count > 0)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(
|
||||
$"Database(s) unavailable: {string.Join(", ", unhealthyDbs)}",
|
||||
data: data);
|
||||
}
|
||||
|
||||
if (degradedDbs.Count > 0)
|
||||
{
|
||||
return HealthCheckResult.Degraded(
|
||||
$"Database(s) slow/degraded: {string.Join(", ", degradedDbs)}",
|
||||
data: data);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("All databases connected", data: data);
|
||||
}
|
||||
|
||||
private async Task CheckLotFinderAsync(
|
||||
Dictionary<string, object> data,
|
||||
List<string> unhealthyDbs,
|
||||
List<string> degradedDbs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string name = "LotFinder";
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(ConnectionTimeout);
|
||||
|
||||
await using var conn = await _connectionFactory.CreateLotFinderConnectionAsync(cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordTimeout(name, sw.ElapsedMilliseconds, data, unhealthyDbs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, unhealthyDbs);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckJdeAsync(
|
||||
Dictionary<string, object> data,
|
||||
List<string> unhealthyDbs,
|
||||
List<string> degradedDbs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string name = "JDE";
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(ConnectionTimeout);
|
||||
|
||||
await using var conn = await _connectionFactory.CreateJdeConnectionAsync(cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckCmsAsync(
|
||||
Dictionary<string, object> data,
|
||||
List<string> unhealthyDbs,
|
||||
List<string> degradedDbs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string name = "CMS";
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(ConnectionTimeout);
|
||||
|
||||
await using var conn = await _connectionFactory.CreateCmsConnectionAsync(cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckGiwAsync(
|
||||
Dictionary<string, object> data,
|
||||
List<string> unhealthyDbs,
|
||||
List<string> degradedDbs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string name = "GIW";
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(ConnectionTimeout);
|
||||
|
||||
await using var conn = await _connectionFactory.CreateGiwConnectionAsync(cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordSuccess(
|
||||
string name,
|
||||
long elapsedMs,
|
||||
Dictionary<string, object> data,
|
||||
List<string> degradedDbs)
|
||||
{
|
||||
data[$"{name}_Status"] = "Connected";
|
||||
data[$"{name}_ResponseMs"] = elapsedMs;
|
||||
|
||||
if (elapsedMs > DegradedThresholdMs)
|
||||
{
|
||||
degradedDbs.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordTimeout(
|
||||
string name,
|
||||
long elapsedMs,
|
||||
Dictionary<string, object> data,
|
||||
List<string> problemDbs)
|
||||
{
|
||||
data[$"{name}_Status"] = "Timeout";
|
||||
data[$"{name}_ResponseMs"] = elapsedMs;
|
||||
data[$"{name}_Error"] = $"Connection timeout after {ConnectionTimeout.TotalSeconds}s";
|
||||
problemDbs.Add(name);
|
||||
}
|
||||
|
||||
private static void RecordFailure(
|
||||
string name,
|
||||
long elapsedMs,
|
||||
string errorMessage,
|
||||
Dictionary<string, object> data,
|
||||
List<string> problemDbs)
|
||||
{
|
||||
data[$"{name}_Status"] = "Failed";
|
||||
data[$"{name}_ResponseMs"] = elapsedMs;
|
||||
data[$"{name}_Error"] = errorMessage;
|
||||
problemDbs.Add(name);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
|
||||
<PackageReference Include="SqlKata" Version="3.2.3" />
|
||||
<PackageReference Include="SqlKata.Execution" Version="3.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@ using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.Options;
|
||||
using JdeScoping.Core.Validation;
|
||||
using JdeScoping.Infrastructure.Auth;
|
||||
using JdeScoping.Infrastructure.HealthChecks;
|
||||
using JdeScoping.Infrastructure.Options;
|
||||
using JdeScoping.Infrastructure.Security;
|
||||
using JdeScoping.Infrastructure.Validation;
|
||||
@@ -62,6 +63,10 @@ public static class InfrastructureDependencyInjection
|
||||
// Register configuration validators
|
||||
services.AddInfrastructureValidators(configuration);
|
||||
|
||||
// Register health check
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<LdapHealthCheck>("ldap", tags: ["auth", "ready"]);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.DirectoryServices.Protocols;
|
||||
using System.Net;
|
||||
using JdeScoping.Infrastructure.Options;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JdeScoping.Infrastructure.HealthChecks;
|
||||
|
||||
/// <summary>
|
||||
/// Health check for LDAP server connectivity.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see cref="LdapOptions.UseFakeAuth"/> is true (development mode),
|
||||
/// always returns Healthy. Otherwise, checks connectivity to configured LDAP servers.
|
||||
/// </remarks>
|
||||
public class LdapHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly LdapOptions _options;
|
||||
private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LdapHealthCheck"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">The LDAP options.</param>
|
||||
public LdapHealthCheck(IOptions<LdapOptions> options)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Skip check if using fake auth (development mode)
|
||||
if (_options.UseFakeAuth)
|
||||
{
|
||||
return HealthCheckResult.Healthy("Using fake auth (development mode)");
|
||||
}
|
||||
|
||||
var servers = _options.ServerUrls;
|
||||
|
||||
if (servers.Length == 0)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("No LDAP servers configured");
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
// Check connectivity to LDAP servers on a background thread
|
||||
// to avoid blocking the main thread with synchronous LDAP operations
|
||||
return await Task.Run(() => CheckLdapServers(servers, data), cancellationToken);
|
||||
}
|
||||
|
||||
private static HealthCheckResult CheckLdapServers(string[] servers, Dictionary<string, object> data)
|
||||
{
|
||||
var reachableCount = 0;
|
||||
|
||||
foreach (var serverUrl in servers)
|
||||
{
|
||||
var serverKey = SanitizeServerKey(serverUrl);
|
||||
try
|
||||
{
|
||||
// Parse server URL to extract host and port
|
||||
var identifier = CreateLdapIdentifier(serverUrl);
|
||||
using var connection = new LdapConnection(identifier);
|
||||
|
||||
connection.Timeout = ConnectionTimeout;
|
||||
connection.AuthType = AuthType.Anonymous;
|
||||
|
||||
// Attempt anonymous bind to test connectivity
|
||||
connection.Bind();
|
||||
|
||||
data[$"{serverKey}_Status"] = "Reachable";
|
||||
reachableCount++;
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
data[$"{serverKey}_Status"] = "Unreachable";
|
||||
data[$"{serverKey}_Error"] = $"LDAP error: {ex.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data[$"{serverKey}_Status"] = "Unreachable";
|
||||
data[$"{serverKey}_Error"] = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
data["ReachableServers"] = reachableCount;
|
||||
data["TotalServers"] = servers.Length;
|
||||
|
||||
if (reachableCount == 0)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("No LDAP servers reachable", data: data);
|
||||
}
|
||||
|
||||
if (reachableCount < servers.Length)
|
||||
{
|
||||
return HealthCheckResult.Degraded(
|
||||
$"{reachableCount}/{servers.Length} LDAP servers reachable",
|
||||
data: data);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("All LDAP servers reachable", data: data);
|
||||
}
|
||||
|
||||
private static LdapDirectoryIdentifier CreateLdapIdentifier(string serverUrl)
|
||||
{
|
||||
// Handle both plain hostnames and URLs with scheme
|
||||
if (serverUrl.StartsWith("ldap://", StringComparison.OrdinalIgnoreCase) ||
|
||||
serverUrl.StartsWith("ldaps://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var uri = new Uri(serverUrl);
|
||||
var port = uri.Port > 0 ? uri.Port : (uri.Scheme.Equals("ldaps", StringComparison.OrdinalIgnoreCase) ? 636 : 389);
|
||||
return new LdapDirectoryIdentifier(uri.Host, port);
|
||||
}
|
||||
|
||||
// Plain hostname, use default LDAP port
|
||||
return new LdapDirectoryIdentifier(serverUrl);
|
||||
}
|
||||
|
||||
private static string SanitizeServerKey(string serverUrl)
|
||||
{
|
||||
// Create a safe key for the data dictionary
|
||||
return serverUrl
|
||||
.Replace("://", "_")
|
||||
.Replace(":", "_")
|
||||
.Replace("/", "_");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
|
||||
|
||||
Reference in New Issue
Block a user