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); + } +}