diff --git a/.azure/applications/web-api-eu/main.bicep b/.azure/applications/web-api-eu/main.bicep
index 0ed28a2b7..9726cc300 100644
--- a/.azure/applications/web-api-eu/main.bicep
+++ b/.azure/applications/web-api-eu/main.bicep
@@ -83,6 +83,38 @@ resource environmentKeyVaultResource 'Microsoft.KeyVault/vaults@2023-07-01' exis
var containerAppName = '${namePrefix}-webapi-eu-ca'
+var port = 8080
+
+var probes = [
+ {
+ periodSeconds: 5
+ initialDelaySeconds: 2
+ type: 'Liveness'
+ httpGet: {
+ path: '/health/liveness'
+ port: port
+ }
+ }
+ {
+ periodSeconds: 5
+ initialDelaySeconds: 2
+ type: 'Readiness'
+ httpGet: {
+ path: '/health/readiness'
+ port: port
+ }
+ }
+ {
+ periodSeconds: 5
+ initialDelaySeconds: 2
+ type: 'Startup'
+ httpGet: {
+ path: '/health/startup'
+ port: port
+ }
+ }
+]
+
module containerApp '../../modules/containerApp/main.bicep' = {
name: containerAppName
params: {
@@ -94,6 +126,7 @@ module containerApp '../../modules/containerApp/main.bicep' = {
apimIp: apimIp
tags: tags
resources: resources
+ probes: probes
revisionSuffix: revisionSuffix
}
}
diff --git a/.azure/applications/web-api-so/main.bicep b/.azure/applications/web-api-so/main.bicep
index 8a4c63237..32942df6f 100644
--- a/.azure/applications/web-api-so/main.bicep
+++ b/.azure/applications/web-api-so/main.bicep
@@ -87,6 +87,38 @@ resource environmentKeyVaultResource 'Microsoft.KeyVault/vaults@2023-07-01' exis
var containerAppName = '${namePrefix}-webapi-so-ca'
+var port = 8080
+
+var probes = [
+ {
+ periodSeconds: 5
+ initialDelaySeconds: 2
+ type: 'Liveness'
+ httpGet: {
+ path: '/health/liveness'
+ port: port
+ }
+ }
+ {
+ periodSeconds: 5
+ initialDelaySeconds: 2
+ type: 'Readiness'
+ httpGet: {
+ path: '/health/readiness'
+ port: port
+ }
+ }
+ {
+ periodSeconds: 5
+ initialDelaySeconds: 2
+ type: 'Startup'
+ httpGet: {
+ path: '/health/startup'
+ port: port
+ }
+ }
+]
+
module containerApp '../../modules/containerApp/main.bicep' = {
name: containerAppName
params: {
@@ -98,6 +130,8 @@ module containerApp '../../modules/containerApp/main.bicep' = {
apimIp: apimIp
tags: tags
resources: resources
+ probes: probes
+ port: port
revisionSuffix: revisionSuffix
}
}
diff --git a/.azure/modules/containerApp/main.bicep b/.azure/modules/containerApp/main.bicep
index 29b75bb21..7eb404bef 100644
--- a/.azure/modules/containerApp/main.bicep
+++ b/.azure/modules/containerApp/main.bicep
@@ -28,30 +28,12 @@ param resources object?
@description('The suffix for the revision of the container app')
param revisionSuffix string
+@description('The probes for the container app')
+param probes array = []
+
// Container app revision name does not allow '.' character
var cleanedRevisionSuffix = replace(revisionSuffix, '.', '-')
-var probes = [
- {
- periodSeconds: 5
- initialDelaySeconds: 2
- type: 'Liveness'
- httpGet: {
- path: '/healthz'
- port: port
- }
- }
- {
- periodSeconds: 5
- initialDelaySeconds: 2
- type: 'Readiness'
- httpGet: {
- path: '/healthz'
- port: port
- }
- }
-]
-
var ipSecurityRestrictions = empty(apimIp)
? []
: [
@@ -74,7 +56,6 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
identity: {
type: 'SystemAssigned'
}
-
properties: {
configuration: {
ingress: ingress
diff --git a/Digdir.Domain.Dialogporten.sln b/Digdir.Domain.Dialogporten.sln
index b448b9fbd..04d95ab2f 100644
--- a/Digdir.Domain.Dialogporten.sln
+++ b/Digdir.Domain.Dialogporten.sln
@@ -63,6 +63,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Domain.Dialogporten.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Domain.Dialogporten.Architecture.Tests", "tests\Digdir.Domain.Dialogporten.Architecture.Tests\Digdir.Domain.Dialogporten.Architecture.Tests.csproj", "{E389C7C8-9610-40AC-86DC-769B1B7DC78E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Library.Utils.AspNet", "src\Digdir.Library.Utils.AspNet\Digdir.Library.Utils.AspNet.csproj", "{6A485C65-3613-4A49-A16F-2789119F6F38}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Tool.Dialogporten.SlackNotifier.Tests", "tests\Digdir.Tool.Dialogporten.SlackNotifier.Tests\Digdir.Tool.Dialogporten.SlackNotifier.Tests.csproj", "{F7DF2792-9C83-49F7-B7DD-556E8EC577DB}"
EndProject
Global
@@ -155,6 +157,10 @@ Global
{E389C7C8-9610-40AC-86DC-769B1B7DC78E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E389C7C8-9610-40AC-86DC-769B1B7DC78E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E389C7C8-9610-40AC-86DC-769B1B7DC78E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6A485C65-3613-4A49-A16F-2789119F6F38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6A485C65-3613-4A49-A16F-2789119F6F38}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6A485C65-3613-4A49-A16F-2789119F6F38}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6A485C65-3613-4A49-A16F-2789119F6F38}.Release|Any CPU.Build.0 = Release|Any CPU
{F7DF2792-9C83-49F7-B7DD-556E8EC577DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F7DF2792-9C83-49F7-B7DD-556E8EC577DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7DF2792-9C83-49F7-B7DD-556E8EC577DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -188,6 +194,7 @@ Global
{AF35FFCA-1206-4C08-A003-DA4A1344CCD5} = {CADB8189-4AA1-4732-844A-C41DBF3EC8B7}
{0900E3CF-F9D8-4B29-957F-484B3B028D6D} = {320B47A0-5EB8-4B6E-8C84-90633A1849CA}
{E389C7C8-9610-40AC-86DC-769B1B7DC78E} = {CADB8189-4AA1-4732-844A-C41DBF3EC8B7}
+ {6A485C65-3613-4A49-A16F-2789119F6F38} = {096E9B69-6783-4446-A895-0B6D7729A0D9}
{F7DF2792-9C83-49F7-B7DD-556E8EC577DB} = {CADB8189-4AA1-4732-844A-C41DBF3EC8B7}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
diff --git a/docs/schema/V1/swagger.verified.json b/docs/schema/V1/swagger.verified.json
index 7ddf03bbc..e17759b31 100644
--- a/docs/schema/V1/swagger.verified.json
+++ b/docs/schema/V1/swagger.verified.json
@@ -7323,4 +7323,4 @@
"url": "https://altinn-dev-api.azure-api.net/dialogporten"
}
]
-}
+}
\ No newline at end of file
diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj
index 54a0e7536..315a06178 100644
--- a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj
+++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj
@@ -15,6 +15,8 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
true
+
+
diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/HealthChecks/RedisHealthCheck.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/HealthChecks/RedisHealthCheck.cs
new file mode 100644
index 000000000..4e88b8be6
--- /dev/null
+++ b/src/Digdir.Domain.Dialogporten.Infrastructure/HealthChecks/RedisHealthCheck.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using StackExchange.Redis;
+using Microsoft.Extensions.Options;
+
+namespace Digdir.Domain.Dialogporten.Infrastructure.HealthChecks;
+
+internal sealed class RedisHealthCheck : IHealthCheck
+{
+ private readonly InfrastructureSettings _settings;
+
+ public RedisHealthCheck(IOptions options)
+ {
+ _settings = options?.Value ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ using var redis = await ConnectionMultiplexer.ConnectAsync(_settings.Redis.ConnectionString);
+ var db = redis.GetDatabase();
+ await db.PingAsync();
+ return HealthCheckResult.Healthy("Redis connection is healthy.");
+ }
+ catch (RedisConnectionException ex)
+ {
+ return HealthCheckResult.Unhealthy("Unable to connect to Redis.", exception: ex);
+ }
+ catch (Exception ex)
+ {
+ return HealthCheckResult.Unhealthy("An unexpected error occurred while checking Redis health.", exception: ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs
index a6cd65d54..be49b2f21 100644
--- a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs
+++ b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs
@@ -33,6 +33,8 @@
using StackExchange.Redis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.NullObjects;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Digdir.Domain.Dialogporten.Infrastructure.HealthChecks;
namespace Digdir.Domain.Dialogporten.Infrastructure;
@@ -199,6 +201,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
})
.AddPolicyHandlerFromRegistry(PollyPolicy.DefaultHttpRetryPolicy);
+ services.AddCustomHealthChecks();
+
if (environment.IsDevelopment())
{
var localDeveloperSettings = configuration.GetLocalDevelopmentSettings();
@@ -212,6 +216,17 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
return services;
}
+ private static IServiceCollection AddCustomHealthChecks(this IServiceCollection services)
+ {
+ services.AddHealthChecks()
+ .AddCheck("redis", tags: ["dependencies", "redis"])
+ .AddDbContextCheck("postgres", tags: ["dependencies", "critical"]);
+
+ services.AddSingleton();
+
+ return services;
+ }
+
private static IServiceCollection AddGraphQlRedisSubscriptions(this IServiceCollection services,
string redisConnectionString)
{
@@ -295,4 +310,4 @@ private static IServiceCollection ConfigureFusionCache(this IServiceCollection s
return services;
}
-}
+}
\ No newline at end of file
diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj b/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj
index adf2a2a98..a61cff6e6 100644
--- a/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj
+++ b/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj
@@ -21,7 +21,8 @@
+
-
+
\ No newline at end of file
diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Program.cs b/src/Digdir.Domain.Dialogporten.WebApi/Program.cs
index 0ff2a5188..1367bcd63 100644
--- a/src/Digdir.Domain.Dialogporten.WebApi/Program.cs
+++ b/src/Digdir.Domain.Dialogporten.WebApi/Program.cs
@@ -23,6 +23,8 @@
using Microsoft.AspNetCore.Authorization;
using NSwag;
using Serilog;
+using Digdir.Library.Utils.AspNet;
+using Microsoft.Extensions.Options;
// Using two-stage initialization to catch startup errors.
Log.Logger = new LoggerConfiguration()
@@ -125,12 +127,17 @@ static void BuildAndRun(string[] args)
.AddControllers(options => options.InputFormatters.Insert(0, JsonPatchInputFormatter.Get()))
.AddNewtonsoftJson()
.Services
-
+ // Add health checks with the retrieved URLs
+ .AddAspNetHealthChecks((x, y) => x.HealthCheckSettings.HttpGetEndpointsToCheck = y
+ .GetRequiredService>().Value?
+ .Authentication?
+ .JwtBearerTokenSchemas?
+ .Select(z => z.WellKnown)
+ .ToList() ?? [])
// Auth
.AddDialogportenAuthentication(builder.Configuration)
- .AddAuthorization()
- .AddHealthChecks();
+ .AddAuthorization();
if (builder.Environment.IsDevelopment())
{
@@ -147,6 +154,9 @@ static void BuildAndRun(string[] args)
var app = builder.Build();
+ app.MapAspNetHealthChecks()
+ .MapControllers();
+
app.UseHttpsRedirection()
.UseSerilogRequestLogging()
.UseDefaultExceptionHandler()
@@ -199,8 +209,7 @@ static void BuildAndRun(string[] args)
var dialogPrefix = builder.Environment.IsDevelopment() ? "" : "/dialogporten";
uiConfig.DocumentPath = dialogPrefix + "/swagger/{documentName}/swagger.json";
});
- app.MapControllers();
- app.MapHealthChecks("/healthz");
+
app.Run();
}
diff --git a/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesExtensions.cs b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesExtensions.cs
new file mode 100644
index 000000000..aa3f52ca4
--- /dev/null
+++ b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesExtensions.cs
@@ -0,0 +1,48 @@
+using Digdir.Library.Utils.AspNet.HealthChecks;
+using HealthChecks.UI.Client;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+
+namespace Digdir.Library.Utils.AspNet;
+
+public static class AspNetUtilitiesExtensions
+{
+ public static IServiceCollection AddAspNetHealthChecks(
+ this IServiceCollection services,
+ Action? configure = null)
+ => services.AddAspNetHealthChecks((x, _) => configure?.Invoke(x));
+
+ public static IServiceCollection AddAspNetHealthChecks(this IServiceCollection services, Action? configure = null)
+ {
+ var optionsBuilder = services.AddOptions();
+
+ if (configure is not null)
+ {
+ optionsBuilder.Configure(configure);
+ }
+
+ return services
+ .AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["self"])
+ .AddCheck(
+ "Endpoints",
+ failureStatus: HealthStatus.Unhealthy,
+ tags: ["external"])
+ .Services;
+ }
+
+ public static WebApplication MapAspNetHealthChecks(this WebApplication app) =>
+ app.MapHealthCheckEndpoint("/health/startup", check => check.Tags.Contains("dependencies"))
+ .MapHealthCheckEndpoint("/health/liveness", check => check.Tags.Contains("self"))
+ .MapHealthCheckEndpoint("/health/readiness", check => check.Tags.Contains("critical"))
+ .MapHealthCheckEndpoint("/health", check => check.Tags.Contains("dependencies"))
+ .MapHealthCheckEndpoint("/health/deep", check => check.Tags.Contains("dependencies") || check.Tags.Contains("external"));
+
+ private static WebApplication MapHealthCheckEndpoint(this WebApplication app, string path, Func predicate)
+ {
+ app.MapHealthChecks(path, new HealthCheckOptions { Predicate = predicate, ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse });
+ return app;
+ }
+}
diff --git a/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesSettings.cs b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesSettings.cs
new file mode 100644
index 000000000..f8e2f7fdb
--- /dev/null
+++ b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesSettings.cs
@@ -0,0 +1,11 @@
+namespace Digdir.Library.Utils.AspNet;
+
+public sealed class AspNetUtilitiesSettings
+{
+ public HealthCheckSettings HealthCheckSettings { get; set; } = new();
+}
+
+public sealed class HealthCheckSettings
+{
+ public List HttpGetEndpointsToCheck { get; set; } = [];
+}
diff --git a/src/Digdir.Library.Utils.AspNet/Digdir.Library.Utils.AspNet.csproj b/src/Digdir.Library.Utils.AspNet/Digdir.Library.Utils.AspNet.csproj
new file mode 100644
index 000000000..f340a8f0a
--- /dev/null
+++ b/src/Digdir.Library.Utils.AspNet/Digdir.Library.Utils.AspNet.csproj
@@ -0,0 +1,13 @@
+
+
+
+ true
+ 1591
+
+
+
+
+
+
+
+
diff --git a/src/Digdir.Library.Utils.AspNet/HealthChecks/EndpointsHealthCheck.cs b/src/Digdir.Library.Utils.AspNet/HealthChecks/EndpointsHealthCheck.cs
new file mode 100644
index 000000000..98e7567ee
--- /dev/null
+++ b/src/Digdir.Library.Utils.AspNet/HealthChecks/EndpointsHealthCheck.cs
@@ -0,0 +1,59 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.Collections.Concurrent;
+
+namespace Digdir.Library.Utils.AspNet.HealthChecks;
+
+internal sealed class EndpointsHealthCheck : IHealthCheck
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+ private readonly List _endpoints;
+
+ public EndpointsHealthCheck(
+ IHttpClientFactory httpClientFactory,
+ ILogger logger,
+ IOptions options)
+ {
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _endpoints = options.Value.HealthCheckSettings.HttpGetEndpointsToCheck;
+ }
+
+ public async Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ var client = _httpClientFactory.CreateClient();
+ var unhealthyEndpoints = new ConcurrentBag();
+
+ var tasks = _endpoints.Select(async url =>
+ {
+ try
+ {
+ var response = await client.GetAsync(url, cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Health check failed for endpoint: {Url}. Status Code: {StatusCode}", url, response.StatusCode);
+ unhealthyEndpoints.Add($"{url} (Status Code: {response.StatusCode})");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception occurred while checking endpoint: {Url}", url);
+ unhealthyEndpoints.Add($"{url} (Exception: {ex.GetType().Name})");
+ }
+ });
+
+ await Task.WhenAll(tasks);
+
+ if (unhealthyEndpoints.IsEmpty)
+ {
+ return HealthCheckResult.Healthy("All endpoints are healthy.");
+ }
+
+ var description = $"The following endpoints are unhealthy: {string.Join(", ", unhealthyEndpoints)}";
+ return HealthCheckResult.Unhealthy(description);
+ }
+}