using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scanner.WebService.Diagnostics; using StellaOps.Scanner.WebService.Options; using StellaOps.Scanner.Surface.Env; using StellaOps.Scanner.Surface.Validation; namespace StellaOps.Scanner.WebService.Endpoints; internal static class HealthEndpoints { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public static void MapHealthEndpoints(this IEndpointRouteBuilder endpoints) { ArgumentNullException.ThrowIfNull(endpoints); var group = endpoints.MapGroup("/"); group.MapGet("/healthz", HandleHealth) .WithName("scanner.health") .Produces(StatusCodes.Status200OK) .AllowAnonymous(); group.MapGet("/readyz", HandleReady) .WithName("scanner.ready") .Produces(StatusCodes.Status200OK) .AllowAnonymous(); } private static IResult HandleHealth( ServiceStatus status, IOptions options, HttpContext context) { ApplyNoCache(context.Response); var snapshot = status.CreateSnapshot(); var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); var telemetry = new TelemetrySnapshot( Enabled: options.Value.Telemetry.Enabled, Logging: options.Value.Telemetry.EnableLogging, Metrics: options.Value.Telemetry.EnableMetrics, Tracing: options.Value.Telemetry.EnableTracing); var document = new HealthDocument( Status: "healthy", StartedAt: snapshot.StartedAt, CapturedAt: snapshot.CapturedAt, UptimeSeconds: uptimeSeconds, Telemetry: telemetry); return Json(document, StatusCodes.Status200OK); } private static async Task HandleReady( ServiceStatus status, ISurfaceValidatorRunner validatorRunner, ISurfaceEnvironment surfaceEnvironment, ILoggerFactory loggerFactory, HttpContext context, CancellationToken cancellationToken) { ApplyNoCache(context.Response); ArgumentNullException.ThrowIfNull(loggerFactory); var logger = loggerFactory.CreateLogger("Scanner.WebService.Health"); var stopwatch = Stopwatch.StartNew(); var success = true; string? error = null; try { var validationContext = SurfaceValidationContext.Create( context.RequestServices, "Scanner.WebService.ReadyCheck", surfaceEnvironment.Settings, properties: new Dictionary { ["path"] = context.Request.Path.ToString() }); await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (SurfaceValidationException ex) { success = false; error = ex.Message; } catch (Exception ex) { success = false; error = ex.Message; logger.LogError(ex, "Surface validation failed during ready check."); } finally { stopwatch.Stop(); } status.RecordReadyCheck(success, stopwatch.Elapsed, error); var snapshot = status.CreateSnapshot(); var ready = snapshot.Ready; var document = new ReadyDocument( Status: ready.IsReady ? "ready" : "unready", CheckedAt: ready.CheckedAt, LatencyMs: ready.Latency?.TotalMilliseconds, Error: ready.Error); var statusCode = success ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable; return Json(document, statusCode); } private static void ApplyNoCache(HttpResponse response) { response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate"; response.Headers.Pragma = "no-cache"; response.Headers["Expires"] = "0"; } private static IResult Json(T value, int statusCode) { var payload = JsonSerializer.Serialize(value, JsonOptions); return Results.Content(payload, "application/json", Encoding.UTF8, statusCode); } internal sealed record TelemetrySnapshot( bool Enabled, bool Logging, bool Metrics, bool Tracing); internal sealed record HealthDocument( string Status, DateTimeOffset StartedAt, DateTimeOffset CapturedAt, double UptimeSeconds, TelemetrySnapshot Telemetry); internal sealed record ReadyDocument( string Status, DateTimeOffset CheckedAt, double? LatencyMs, string? Error); }