diff --git a/Directory.Packages.props b/Directory.Packages.props index f348abf3b..b440c9775 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,42 +3,44 @@ true - - - - - - - + + + + + + + - - + + + + - + - - + + - - + + - + - - + + - - + + \ No newline at end of file diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 971c98acf..55f890b4b 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -172,16 +172,27 @@ If not specified, the `MaxRequestBodySize` in BaGetter defaults to 250MB (262144 ## Health Endpoint -When running within a containerized environment like Kubernetes, a basic health endpoint is exposed at `/health` that returns 200 OK and the text "Healthy" when running. +A health endpoint is exposed at `/health` that returns 200 OK or 503 Service Unavailable and always includes a json object listing the current status of the application: -This path is configurable if needed: +```json +{ + "Status": "Healthy", + "Sqlite": "Healthy", + ... +} +``` + +The services can be omitted by setting the `Statistics:ListConfiguredServices` to false, in which case only the `Status` property is returned in the json object. + +This path and the name of the "Status" property are configurable if needed: ```json { ... "HealthCheck": { - "Path": "/healthz" + "Path": "/healthz", + "StatusPropertyName": "Status" }, ... diff --git a/src/BaGetter.Core/BaGetter.Core.csproj b/src/BaGetter.Core/BaGetter.Core.csproj index 160f51f0b..0bc307799 100644 --- a/src/BaGetter.Core/BaGetter.Core.csproj +++ b/src/BaGetter.Core/BaGetter.Core.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/BaGetter.Core/Configuration/HealthCheckOptions.cs b/src/BaGetter.Core/Configuration/HealthCheckOptions.cs index d0e11af45..2e673a283 100644 --- a/src/BaGetter.Core/Configuration/HealthCheckOptions.cs +++ b/src/BaGetter.Core/Configuration/HealthCheckOptions.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace BaGetter.Core; @@ -8,6 +8,11 @@ public class HealthCheckOptions : IValidatableObject [Required] public string Path { get; set; } + /// + /// What the overall status property is called in the health check response. Default is "Status". + /// + public string StatusPropertyName { get; set; } = "Status"; + public IEnumerable Validate(ValidationContext validationContext) { if (! Path.StartsWith('/')) diff --git a/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.Providers.cs b/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.Providers.cs index b96a0d8cb..b0de4b776 100644 --- a/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.Providers.cs +++ b/src/BaGetter.Core/Extensions/DependencyInjectionExtensions.Providers.cs @@ -106,6 +106,9 @@ public static IServiceCollection AddBaGetDbContextProvider( return provider.GetRequiredService(); }); + services.AddHealthChecks() + .AddDbContextCheck(databaseType, tags: [databaseType]); + return services; } diff --git a/src/BaGetter.Core/Extensions/HealthCheckExtensions.cs b/src/BaGetter.Core/Extensions/HealthCheckExtensions.cs new file mode 100644 index 000000000..059b51370 --- /dev/null +++ b/src/BaGetter.Core/Extensions/HealthCheckExtensions.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace BaGetter.Core.Extensions; + +public static class HealthCheckExtensions +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + /// + /// Formats the as JSON and writes it to the specified . + /// + /// The report to format. + /// A writable stream to write the report to. Will not be closed. + /// Whether to include detailed information about each health check. + /// The name of the property that will contain the overall status. + /// + /// A completing when the report is completely written to the stream. + public static async Task FormatAsJson(this HealthReport report, Stream stream, bool detailedReport, string statusPropertyName = "Status", + CancellationToken cancellationToken = default) + { + // Always include the overall status. + IEnumerable<(string Key, HealthStatus Value)> entries = [(statusPropertyName, report.Status)]; + + // Include details if requested. + if (detailedReport) + { + entries = entries.Concat(report.Entries.Select(entry => (entry.Key, entry.Value.Status))); + } + + await JsonSerializer.SerializeAsync( + stream, + entries.ToDictionary(entry => entry.Key, entry => entry.Value), + SerializerOptions, + cancellationToken); + } + + /// + /// Determine whether a health check is configured for BaGetter. + /// + /// The . + /// The current BaGetter configuration. Will be checked for configured services. + /// A boolean representing whether the given check is configured in this BaGetter instance. + public static bool IsConfigured(this HealthCheckRegistration check, BaGetterOptions options) + { + return check.Tags.Count == 0 || // General checks + check.Tags.Contains(options.Database.Type) || // Database check + check.Tags.Contains(options.Storage.Type) || // Storage check + check.Tags.Contains(options.Search.Type); // Search check + } +} diff --git a/src/BaGetter/Startup.cs b/src/BaGetter/Startup.cs index b0692dc0f..520555782 100644 --- a/src/BaGetter/Startup.cs +++ b/src/BaGetter/Startup.cs @@ -1,5 +1,6 @@ using System; using BaGetter.Core; +using BaGetter.Core.Extensions; using BaGetter.Web; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -8,18 +9,19 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using HealthCheckOptions = Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions; namespace BaGetter; public class Startup { + private IConfiguration Configuration { get; } + public Startup(IConfiguration configuration) { Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); } - private IConfiguration Configuration { get; } - public void ConfigureServices(IServiceCollection services) { services.ConfigureOptions(); @@ -94,6 +96,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) baget.MapEndpoints(endpoints); }); - app.UseHealthChecks(options.HealthCheck.Path); + app.UseHealthChecks(options.HealthCheck.Path, + new HealthCheckOptions + { + ResponseWriter = async (context, report) => + { + await report.FormatAsJson(context.Response.Body, options.Statistics.ListConfiguredServices, options.HealthCheck.StatusPropertyName, + context.RequestAborted); + }, + Predicate = check => check.IsConfigured(options) + } + ); } } diff --git a/tests/BaGetter.Tests/Support/BaGetApplication.cs b/tests/BaGetter.Tests/Support/BaGetApplication.cs index 2a6a90f26..128d95bbc 100644 --- a/tests/BaGetter.Tests/Support/BaGetApplication.cs +++ b/tests/BaGetter.Tests/Support/BaGetApplication.cs @@ -12,6 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using Moq; using Xunit.Abstractions; @@ -82,6 +83,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(_upstreamClient); } + services.Configure(opts => opts.Registrations.Clear()); + // Setup the integration test database. var provider = services.BuildServiceProvider(); var scopeFactory = provider.GetRequiredService();