Skip to content

Commit b6d1e12

Browse files
authored
Merge pull request #175 from teamssUTXO/master
feat : Add health endpoint
2 parents 5a33458 + 3605f18 commit b6d1e12

12 files changed

Lines changed: 278 additions & 17 deletions

File tree

PluginBuilder/Controllers/HomeController.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.AspNetCore.Identity;
55
using Microsoft.AspNetCore.Mvc;
66
using Microsoft.AspNetCore.RateLimiting;
7+
using Microsoft.Extensions.Diagnostics.HealthChecks;
78
using Newtonsoft.Json.Linq;
89
using PluginBuilder.APIModels;
910
using PluginBuilder.Components.PluginVersion;
@@ -33,7 +34,8 @@ public class HomeController(
3334
PluginBuilderOptions options,
3435
ServerEnvironment env,
3536
NostrService nostrService,
36-
ILogger<HomeController> logger)
37+
ILogger<HomeController> logger,
38+
HealthCheckService healthCheckService)
3739
: Controller
3840
{
3941
[AllowAnonymous]
@@ -793,4 +795,36 @@ public IActionResult Error()
793795
{
794796
return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
795797
}
798+
799+
[AllowAnonymous]
800+
[HttpGet("/health")]
801+
[EnableRateLimiting(Policies.PublicApiRateLimit)]
802+
public async Task<IActionResult> CheckHealth(CancellationToken cancellationToken)
803+
{
804+
var report = await healthCheckService.CheckHealthAsync(cancellationToken);
805+
806+
var result = new
807+
{
808+
status = report.Status == HealthStatus.Healthy ? "UP" : "DOWN",
809+
timestamp = DateTime.UtcNow,
810+
description = report.Entries.Values.FirstOrDefault(e => e.Description is not null).Description
811+
};
812+
813+
// display page
814+
var acceptHeader = Request.Headers.Accept.ToString();
815+
if (acceptHeader.Contains("text/html"))
816+
{
817+
var hcvm = new HealthCheckViewModel { Healthy = result.status, Description = result.description ?? "" };
818+
819+
var view = View("HealthPage", hcvm);
820+
view.StatusCode = report.Status == HealthStatus.Unhealthy
821+
? StatusCodes.Status503ServiceUnavailable
822+
: StatusCodes.Status200OK;
823+
824+
return view;
825+
}
826+
827+
// send JSON result
828+
return report.Status == HealthStatus.Healthy ? Ok(result) : StatusCode(StatusCodes.Status503ServiceUnavailable, result);
829+
}
796830
}

PluginBuilder/HostedServices/AzureStartupHostedService.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ namespace PluginBuilder.HostedServices;
44

55
public class AzureStartupHostedService : IHostedService
66
{
7+
public static bool AzureStartupCompleted { get; private set; }
8+
public static Exception? AzureStartupError { get; private set; }
9+
710
public AzureStartupHostedService(AzureStorageClient azureStorageClient)
811
{
912
AzureStorageClient = azureStorageClient;
@@ -13,7 +16,19 @@ public AzureStartupHostedService(AzureStorageClient azureStorageClient)
1316

1417
public async Task StartAsync(CancellationToken cancellationToken)
1518
{
16-
await AzureStorageClient.EnsureDefaultContainerExists(cancellationToken);
19+
AzureStartupCompleted = false;
20+
AzureStartupError = null;
21+
22+
try
23+
{
24+
await AzureStorageClient.EnsureDefaultContainerExists(cancellationToken);
25+
AzureStartupCompleted = true;
26+
}
27+
catch (Exception ex)
28+
{
29+
AzureStartupError = ex;
30+
throw;
31+
}
1732
}
1833

1934
public Task StopAsync(CancellationToken cancellationToken)

PluginBuilder/HostedServices/DatabaseStartupHostedService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ public class DatabaseStartupHostedService : IHostedService
1313
{
1414
private readonly AdminSettingsCache _adminSettingsCache;
1515

16+
public static bool DatabaseStartupCompleted { get; private set; }
17+
public static Exception? DatabaseStartupError { get; private set; }
18+
1619
public DatabaseStartupHostedService(ILogger<DatabaseStartupHostedService> logger, DBConnectionFactory connectionFactory,
1720
AdminSettingsCache adminSettingsCache)
1821
{
@@ -27,6 +30,9 @@ public DatabaseStartupHostedService(ILogger<DatabaseStartupHostedService> logger
2730

2831
public async Task StartAsync(CancellationToken cancellationToken)
2932
{
33+
DatabaseStartupCompleted = false;
34+
DatabaseStartupError = null;
35+
3036
retry:
3137
try
3238
{
@@ -36,6 +42,8 @@ public async Task StartAsync(CancellationToken cancellationToken)
3642

3743
await conn.SettingsInitialize();
3844
await _adminSettingsCache.RefreshAllAdminSettings(conn);
45+
46+
DatabaseStartupCompleted = true;
3947
}
4048
catch (NpgsqlException pgex) when (pgex.SqlState == "3D000")
4149
{
@@ -52,6 +60,11 @@ public async Task StartAsync(CancellationToken cancellationToken)
5260

5361
goto retry;
5462
}
63+
catch (Exception ex)
64+
{
65+
DatabaseStartupError = ex;
66+
throw;
67+
}
5568
}
5669

5770
public Task StopAsync(CancellationToken cancellationToken)

PluginBuilder/HostedServices/DockerStartupHostedService.cs

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ public class DockerStartupException : Exception
55
public DockerStartupException(string message) : base(message)
66
{
77
}
8+
9+
public static bool DockerStartupCompleted { get; private set; }
10+
public static Exception? DockerStartupError { get; private set; }
11+
12+
public static void ResetStartupState()
13+
{
14+
DockerStartupCompleted = false;
15+
DockerStartupError = null;
16+
}
17+
18+
public static void MarkStartupCompleted()
19+
{
20+
DockerStartupCompleted = true;
21+
}
22+
23+
public static void MarkStartupFailed(Exception ex)
24+
{
25+
DockerStartupError = ex;
26+
DockerStartupCompleted = false;
27+
}
828
}
929

1030
public class DockerStartupHostedService : IHostedService
@@ -24,33 +44,45 @@ public DockerStartupHostedService(ILogger<DockerStartupHostedService> logger, IW
2444

2545
public async Task StartAsync(CancellationToken cancellationToken)
2646
{
47+
DockerStartupException.ResetStartupState();
48+
2749
var skipBuildValue = Environment.GetEnvironmentVariable(SkipBuildEnvVar);
2850
var skipBuild = string.Equals(skipBuildValue, "1", StringComparison.OrdinalIgnoreCase) ||
2951
string.Equals(skipBuildValue, "true", StringComparison.OrdinalIgnoreCase);
3052

3153
if (skipBuild)
3254
{
3355
Logger.LogInformation("Skipping docker image build because {SkipBuildEnvVar}=true", SkipBuildEnvVar);
56+
DockerStartupException.MarkStartupCompleted();
3457
return;
3558
}
3659

37-
Logger.LogInformation("Building the PluginBuilder docker image");
38-
39-
var buildResult = await ProcessRunner.RunAsync(new ProcessSpec
60+
try
4061
{
41-
Executable = "docker",
42-
EnvironmentVariables =
62+
Logger.LogInformation("Building the PluginBuilder docker image");
63+
64+
var buildResult = await ProcessRunner.RunAsync(new ProcessSpec
4365
{
44-
// Somehow we get permission problem when buildkit isn't used
45-
["DOCKER_BUILDKIT"] = "1"
46-
},
47-
Arguments = new[] { "build", "-f", "PluginBuilder.Dockerfile", "-t", "plugin-builder", "." },
48-
WorkingDirectory = ContentRootPath
49-
}, cancellationToken);
50-
if (buildResult != 0)
51-
throw new DockerStartupException("The build of PluginBuilder.Dockerfile failed");
52-
53-
await CleanupDanglingBuildVolumes(cancellationToken);
66+
Executable = "docker",
67+
EnvironmentVariables =
68+
{
69+
// Somehow we get permission problem when buildkit isn't used
70+
["DOCKER_BUILDKIT"] = "1"
71+
},
72+
Arguments = new[] { "build", "-f", "PluginBuilder.Dockerfile", "-t", "plugin-builder", "." },
73+
WorkingDirectory = ContentRootPath
74+
}, cancellationToken);
75+
if (buildResult != 0)
76+
throw new DockerStartupException("The build of PluginBuilder.Dockerfile failed");
77+
78+
await CleanupDanglingBuildVolumes(cancellationToken);
79+
DockerStartupException.MarkStartupCompleted();
80+
}
81+
catch (Exception ex)
82+
{
83+
DockerStartupException.MarkStartupFailed(ex);
84+
throw;
85+
}
5486
}
5587

5688
public Task StopAsync(CancellationToken cancellationToken)

PluginBuilder/PluginBuilder.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@
123123
</ItemGroup>
124124

125125
<ItemGroup>
126+
<Folder Include="wwwroot\img\healthpage\" />
126127
<Folder Include="wwwroot\vendor\highlight.js\"/>
127128
<Folder Include="wwwroot\vendor\signalr\"/>
128129
</ItemGroup>

PluginBuilder/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ private void ConfigureBuilder(WebApplicationBuilder builder)
8181
if (!verbose)
8282
builder.Logging.AddFilter("Events", LogLevel.Warning);
8383

84+
builder.Services.AddHealthChecks().AddCheck<HealthService>("Dependencies");
85+
8486
// Uncomment this to see EF queries
8587
// builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Trace);
8688
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information);
@@ -192,6 +194,7 @@ public void AddServices(IConfiguration configuration, IServiceCollection service
192194
services.AddSingleton<AzureStorageClient>();
193195
services.AddSingleton<ServerEnvironment>();
194196
services.AddSingleton<EventAggregator>();
197+
services.AddSingleton<HealthService>();
195198
services.AddHttpClient(HttpClientNames.GitHub, client =>
196199
{
197200
client.BaseAddress = new Uri("https://api.github.com/");

PluginBuilder/Services/AzureStorageClient.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ public async Task<bool> EnsureDefaultContainerExists(CancellationToken cancellat
6262
return ToJson(output)["created"]!.Value<bool>();
6363
}
6464

65+
public async Task<bool> IsDefaultContainerAccessible(CancellationToken cancellationToken = default)
66+
{
67+
try
68+
{
69+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
70+
cts.CancelAfter(TimeSpan.FromSeconds(15));
71+
72+
var container = blobClient.GetContainerReference(DefaultContainer);
73+
return await container.ExistsAsync(null, null, cts.Token);
74+
}
75+
catch
76+
{
77+
return false;
78+
}
79+
}
80+
6581
public async Task<string> UploadImageFile(IFormFile file, string blobName)
6682
{
6783
var container = blobClient.GetContainerReference(DefaultContainer);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using Microsoft.Extensions.Diagnostics.HealthChecks;
2+
using Npgsql;
3+
4+
namespace PluginBuilder.Services;
5+
6+
public class HealthService : IHealthCheck
7+
{
8+
public HealthService(DBConnectionFactory dbConnectionFactory, AzureStorageClient azureStorageClient, ProcessRunner processRunner, IHostApplicationLifetime lifetime)
9+
{
10+
DbConnectionFactory = dbConnectionFactory;
11+
AzureStorageClient = azureStorageClient;
12+
ProcessRunner = processRunner;
13+
14+
_lifetime = lifetime;
15+
}
16+
17+
private readonly IHostApplicationLifetime _lifetime;
18+
19+
private DBConnectionFactory DbConnectionFactory { get; }
20+
private AzureStorageClient AzureStorageClient { get; }
21+
private ProcessRunner ProcessRunner { get; }
22+
23+
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
24+
{
25+
if (!_lifetime.ApplicationStarted.IsCancellationRequested)
26+
return HealthCheckResult.Unhealthy("Startup incomplete");
27+
28+
var dbTask = IsDatabaseHealthy(cancellationToken);
29+
var dockerTask = IsDockerHealthy(cancellationToken);
30+
var azureTask = AzureStorageClient.IsDefaultContainerAccessible(cancellationToken);
31+
32+
await Task.WhenAll(dbTask, dockerTask, azureTask);
33+
34+
var isHealthy = dbTask.Result && dockerTask.Result && azureTask.Result;
35+
36+
return isHealthy
37+
? HealthCheckResult.Healthy()
38+
: HealthCheckResult.Unhealthy("Critical dependency unavailable");
39+
}
40+
41+
private async Task<bool> IsDatabaseHealthy(CancellationToken cancellationToken)
42+
{
43+
try
44+
{
45+
await using var conn = await DbConnectionFactory.Open(cancellationToken);
46+
await using var cmd = new NpgsqlCommand("SELECT 1", conn);
47+
var result = await cmd.ExecuteScalarAsync(cancellationToken);
48+
return result is 1;
49+
}
50+
catch
51+
{
52+
return false;
53+
}
54+
}
55+
56+
private async Task<bool> IsDockerHealthy(CancellationToken cancellationToken)
57+
{
58+
try
59+
{
60+
var code = await ProcessRunner.RunAsync(new ProcessSpec
61+
{
62+
Executable = "docker",
63+
Arguments = ["info", "--format", "{{ .ServerVersion }}"],
64+
OutputCapture = new OutputCapture(),
65+
ErrorCapture = new OutputCapture()
66+
}, cancellationToken);
67+
return code == 0;
68+
}
69+
catch
70+
{
71+
return false;
72+
}
73+
}
74+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace PluginBuilder.ViewModels.Home;
2+
3+
public class HealthCheckViewModel
4+
{
5+
public string Healthy { get; set; } = string.Empty;
6+
7+
public string Description { get; set; } = string.Empty;
8+
}

0 commit comments

Comments
 (0)