From 993126273a8bb12fe05da03ec39abed1dae822f2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 30 Jan 2026 07:24:33 -0500 Subject: [PATCH] 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. --- NEW/src/JdeScoping.Api/DependencyInjection.cs | 29 ++- .../DependencyInjection.cs | 5 + .../HealthChecks/DatabaseHealthCheck.cs | 224 ++++++++++++++++++ .../JdeScoping.DataAccess.csproj | 1 + .../DependencyInjection.cs | 5 + .../HealthChecks/LdapHealthCheck.cs | 130 ++++++++++ .../JdeScoping.Infrastructure.csproj | 1 + 7 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 NEW/src/JdeScoping.DataAccess/HealthChecks/DatabaseHealthCheck.cs create mode 100644 NEW/src/JdeScoping.Infrastructure/HealthChecks/LdapHealthCheck.cs diff --git a/NEW/src/JdeScoping.Api/DependencyInjection.cs b/NEW/src/JdeScoping.Api/DependencyInjection.cs index 2227c17..32e6810 100644 --- a/NEW/src/JdeScoping.Api/DependencyInjection.cs +++ b/NEW/src/JdeScoping.Api/DependencyInjection.cs @@ -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; } + + /// + /// Writes detailed JSON response for health check results. + /// + 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); + } } diff --git a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs index 428b75e..571e736 100644 --- a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs +++ b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs @@ -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(); + // Register health check + services.AddHealthChecks() + .AddCheck("database", tags: ["db", "ready"]); + return services; } diff --git a/NEW/src/JdeScoping.DataAccess/HealthChecks/DatabaseHealthCheck.cs b/NEW/src/JdeScoping.DataAccess/HealthChecks/DatabaseHealthCheck.cs new file mode 100644 index 0000000..67424da --- /dev/null +++ b/NEW/src/JdeScoping.DataAccess/HealthChecks/DatabaseHealthCheck.cs @@ -0,0 +1,224 @@ +using System.Diagnostics; +using JdeScoping.DataAccess.Interfaces; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace JdeScoping.DataAccess.HealthChecks; + +/// +/// Health check for database connectivity to all data sources. +/// +/// +/// Checks connectivity to: +/// - LotFinder (SQL Server) - Critical, causes Unhealthy if unavailable +/// - JDE, CMS, GIW (Oracle) - Important but not critical, causes Degraded if unavailable +/// +public class DatabaseHealthCheck : IHealthCheck +{ + private readonly IDbConnectionFactory _connectionFactory; + private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5); + private static readonly long DegradedThresholdMs = 2000; + + /// + /// Initializes a new instance of the class. + /// + /// The database connection factory. + public DatabaseHealthCheck(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var data = new Dictionary(); + var unhealthyDbs = new List(); + var degradedDbs = new List(); + + // 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 data, + List unhealthyDbs, + List 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 data, + List unhealthyDbs, + List 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 data, + List unhealthyDbs, + List 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 data, + List unhealthyDbs, + List 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 data, + List degradedDbs) + { + data[$"{name}_Status"] = "Connected"; + data[$"{name}_ResponseMs"] = elapsedMs; + + if (elapsedMs > DegradedThresholdMs) + { + degradedDbs.Add(name); + } + } + + private static void RecordTimeout( + string name, + long elapsedMs, + Dictionary data, + List 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 data, + List problemDbs) + { + data[$"{name}_Status"] = "Failed"; + data[$"{name}_ResponseMs"] = elapsedMs; + data[$"{name}_Error"] = errorMessage; + problemDbs.Add(name); + } +} diff --git a/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj b/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj index f13cff9..b2bfa0e 100644 --- a/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj +++ b/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj @@ -15,6 +15,7 @@ + diff --git a/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs b/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs index 9e1bf67..60bae97 100644 --- a/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs +++ b/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs @@ -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("ldap", tags: ["auth", "ready"]); + return services; } diff --git a/NEW/src/JdeScoping.Infrastructure/HealthChecks/LdapHealthCheck.cs b/NEW/src/JdeScoping.Infrastructure/HealthChecks/LdapHealthCheck.cs new file mode 100644 index 0000000..e8a287b --- /dev/null +++ b/NEW/src/JdeScoping.Infrastructure/HealthChecks/LdapHealthCheck.cs @@ -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; + +/// +/// Health check for LDAP server connectivity. +/// +/// +/// When is true (development mode), +/// always returns Healthy. Otherwise, checks connectivity to configured LDAP servers. +/// +public class LdapHealthCheck : IHealthCheck +{ + private readonly LdapOptions _options; + private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5); + + /// + /// Initializes a new instance of the class. + /// + /// The LDAP options. + public LdapHealthCheck(IOptions options) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public async Task 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(); + + // 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 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("/", "_"); + } +} diff --git a/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj b/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj index 9a37145..f59f613 100644 --- a/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj +++ b/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj @@ -9,6 +9,7 @@ +