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();