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 @@ +