feat: add health check endpoints for Bot and Web
PR Checks / test-and-build (pull_request) Successful in 8m53s
PR Checks / test-and-build (pull_request) Successful in 8m53s
- Web: add /health endpoint with PostgreSQL readiness check (returns 200+JSON or 503) - Web: add /alive endpoint for liveness probe - Bot: add BotHealthCheckHostedService serving /health on port 8081 via HttpListener - Bot: expose port 8081 in Dockerfile and install wget for healthcheck - compose.yaml: add healthcheck sections for bot and web services - tests: add TDD tests for both health endpoints Bump version -> 1.16.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -30,9 +30,15 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем wget для healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем только AOT-результаты из билда
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
USER $APP_UID
|
||||
|
||||
# Запуск скомпилированного AOT бинарного файла напрямую
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Health;
|
||||
|
||||
public sealed class BotHealthCheckHostedService : IHostedService
|
||||
{
|
||||
private readonly ILogger<BotHealthCheckHostedService> _logger;
|
||||
private readonly string _prefix;
|
||||
private HttpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _listenerTask;
|
||||
|
||||
public BotHealthCheckHostedService(
|
||||
ILogger<BotHealthCheckHostedService> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8081/")!;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add(_prefix);
|
||||
_listener.Start();
|
||||
|
||||
_logger.LogInformation("Health check server started on {Prefix}", _prefix);
|
||||
|
||||
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_listener?.Stop();
|
||||
|
||||
if (_listenerTask != null)
|
||||
{
|
||||
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
|
||||
}
|
||||
|
||||
_listener?.Close();
|
||||
_logger.LogInformation("Health check server stopped");
|
||||
}
|
||||
|
||||
private async Task ListenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _listener.GetContextAsync();
|
||||
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
|
||||
}
|
||||
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in health check listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
|
||||
if (request.Url?.AbsolutePath == "/health")
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.OK;
|
||||
response.ContentType = "application/json";
|
||||
var body = "{\"status\":\"healthy\"}"u8.ToArray();
|
||||
await response.OutputStream.WriteAsync(body);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||
}
|
||||
|
||||
response.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling health check request");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Database;
|
||||
using GmRelay.Bot.Infrastructure.Health;
|
||||
using GmRelay.Bot.Infrastructure.Logging;
|
||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
@@ -85,6 +86,9 @@ builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
||||
|
||||
// ── Health check server ──────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<BotHealthCheckHostedService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
// ── Run database migrations on startup ───────────────────────────────
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v1.15.1</div>
|
||||
<div class="nav-version">v1.16.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Web.Health;
|
||||
|
||||
public sealed class NpgsqlHealthCheck(NpgsqlDataSource dataSource) : IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT 1";
|
||||
await command.ExecuteScalarAsync(cancellationToken);
|
||||
return HealthCheckResult.Healthy();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("PostgreSQL is unavailable", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
using GmRelay.Web.Components;
|
||||
using GmRelay.Web.Health;
|
||||
using GmRelay.Web.Services;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Telegram.Bot;
|
||||
using Npgsql;
|
||||
|
||||
@@ -12,6 +16,10 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Add Aspire service defaults
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
// Add health checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<NpgsqlHealthCheck>("npgsql");
|
||||
|
||||
// Add Data Protection
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo("/app/dataprotection-keys"));
|
||||
@@ -83,6 +91,26 @@ app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
// Health check endpoints
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
ResponseWriter = async (context, report) =>
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
var response = new
|
||||
{
|
||||
status = report.Status == HealthStatus.Healthy ? "healthy" : "unhealthy",
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
||||
};
|
||||
await context.Response.WriteAsJsonAsync(response);
|
||||
}
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/alive", new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
|
||||
// Endpoint to handle Telegram Login callback
|
||||
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user