Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion PluginBuilder/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Newtonsoft.Json.Linq;
using PluginBuilder.APIModels;
using PluginBuilder.Components.PluginVersion;
Expand Down Expand Up @@ -33,7 +34,8 @@ public class HomeController(
PluginBuilderOptions options,
ServerEnvironment env,
NostrService nostrService,
ILogger<HomeController> logger)
ILogger<HomeController> logger,
HealthCheckService healthCheckService)
: Controller
{
[AllowAnonymous]
Expand Down Expand Up @@ -793,4 +795,36 @@ public IActionResult Error()
{
return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}

[AllowAnonymous]
[HttpGet("/health")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> CheckHealth(CancellationToken cancellationToken)
{
var report = await healthCheckService.CheckHealthAsync(cancellationToken);

var result = new
{
status = report.Status == HealthStatus.Healthy ? "UP" : "DOWN",
timestamp = DateTime.UtcNow,
description = report.Entries.Values.FirstOrDefault(e => e.Description is not null).Description
};

// display page
var acceptHeader = Request.Headers.Accept.ToString();
if (acceptHeader.Contains("text/html"))
{
var hcvm = new HealthCheckViewModel { Healthy = result.status, Description = result.description ?? "" };

var view = View("HealthPage", hcvm);
view.StatusCode = report.Status == HealthStatus.Unhealthy
? StatusCodes.Status503ServiceUnavailable
: StatusCodes.Status200OK;

return view;
}

// send JSON result
return report.Status == HealthStatus.Healthy ? Ok(result) : StatusCode(StatusCodes.Status503ServiceUnavailable, result);
}
}
17 changes: 16 additions & 1 deletion PluginBuilder/HostedServices/AzureStartupHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace PluginBuilder.HostedServices;

public class AzureStartupHostedService : IHostedService
{
public static bool AzureStartupCompleted { get; private set; }
public static Exception? AzureStartupError { get; private set; }

public AzureStartupHostedService(AzureStorageClient azureStorageClient)
{
AzureStorageClient = azureStorageClient;
Expand All @@ -13,7 +16,19 @@ public AzureStartupHostedService(AzureStorageClient azureStorageClient)

public async Task StartAsync(CancellationToken cancellationToken)
{
await AzureStorageClient.EnsureDefaultContainerExists(cancellationToken);
AzureStartupCompleted = false;
AzureStartupError = null;

try
{
await AzureStorageClient.EnsureDefaultContainerExists(cancellationToken);
AzureStartupCompleted = true;
}
catch (Exception ex)
{
AzureStartupError = ex;
throw;
}
}

public Task StopAsync(CancellationToken cancellationToken)
Expand Down
13 changes: 13 additions & 0 deletions PluginBuilder/HostedServices/DatabaseStartupHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public class DatabaseStartupHostedService : IHostedService
{
private readonly AdminSettingsCache _adminSettingsCache;

public static bool DatabaseStartupCompleted { get; private set; }
public static Exception? DatabaseStartupError { get; private set; }

public DatabaseStartupHostedService(ILogger<DatabaseStartupHostedService> logger, DBConnectionFactory connectionFactory,
AdminSettingsCache adminSettingsCache)
{
Expand All @@ -27,6 +30,9 @@ public DatabaseStartupHostedService(ILogger<DatabaseStartupHostedService> logger

public async Task StartAsync(CancellationToken cancellationToken)
{
DatabaseStartupCompleted = false;
DatabaseStartupError = null;

retry:
try
{
Expand All @@ -36,6 +42,8 @@ public async Task StartAsync(CancellationToken cancellationToken)

await conn.SettingsInitialize();
await _adminSettingsCache.RefreshAllAdminSettings(conn);

DatabaseStartupCompleted = true;
}
catch (NpgsqlException pgex) when (pgex.SqlState == "3D000")
{
Expand All @@ -52,6 +60,11 @@ public async Task StartAsync(CancellationToken cancellationToken)

goto retry;
}
catch (Exception ex)
{
DatabaseStartupError = ex;
throw;
}
}

public Task StopAsync(CancellationToken cancellationToken)
Expand Down
62 changes: 47 additions & 15 deletions PluginBuilder/HostedServices/DockerStartupHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ public class DockerStartupException : Exception
public DockerStartupException(string message) : base(message)
{
}

public static bool DockerStartupCompleted { get; private set; }
public static Exception? DockerStartupError { get; private set; }

public static void ResetStartupState()
{
DockerStartupCompleted = false;
DockerStartupError = null;
}

public static void MarkStartupCompleted()
{
DockerStartupCompleted = true;
}

public static void MarkStartupFailed(Exception ex)
{
DockerStartupError = ex;
DockerStartupCompleted = false;
}
}

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

public async Task StartAsync(CancellationToken cancellationToken)
{
DockerStartupException.ResetStartupState();

var skipBuildValue = Environment.GetEnvironmentVariable(SkipBuildEnvVar);
var skipBuild = string.Equals(skipBuildValue, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(skipBuildValue, "true", StringComparison.OrdinalIgnoreCase);

if (skipBuild)
{
Logger.LogInformation("Skipping docker image build because {SkipBuildEnvVar}=true", SkipBuildEnvVar);
DockerStartupException.MarkStartupCompleted();
return;
}

Logger.LogInformation("Building the PluginBuilder docker image");

var buildResult = await ProcessRunner.RunAsync(new ProcessSpec
try
{
Executable = "docker",
EnvironmentVariables =
Logger.LogInformation("Building the PluginBuilder docker image");

var buildResult = await ProcessRunner.RunAsync(new ProcessSpec
{
// Somehow we get permission problem when buildkit isn't used
["DOCKER_BUILDKIT"] = "1"
},
Arguments = new[] { "build", "-f", "PluginBuilder.Dockerfile", "-t", "plugin-builder", "." },
WorkingDirectory = ContentRootPath
}, cancellationToken);
if (buildResult != 0)
throw new DockerStartupException("The build of PluginBuilder.Dockerfile failed");

await CleanupDanglingBuildVolumes(cancellationToken);
Executable = "docker",
EnvironmentVariables =
{
// Somehow we get permission problem when buildkit isn't used
["DOCKER_BUILDKIT"] = "1"
},
Arguments = new[] { "build", "-f", "PluginBuilder.Dockerfile", "-t", "plugin-builder", "." },
WorkingDirectory = ContentRootPath
}, cancellationToken);
if (buildResult != 0)
throw new DockerStartupException("The build of PluginBuilder.Dockerfile failed");

await CleanupDanglingBuildVolumes(cancellationToken);
DockerStartupException.MarkStartupCompleted();
}
catch (Exception ex)
{
DockerStartupException.MarkStartupFailed(ex);
throw;
}
}

public Task StopAsync(CancellationToken cancellationToken)
Expand Down
1 change: 1 addition & 0 deletions PluginBuilder/PluginBuilder.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
</ItemGroup>

<ItemGroup>
<Folder Include="wwwroot\img\healthpage\" />
<Folder Include="wwwroot\vendor\highlight.js\"/>
<Folder Include="wwwroot\vendor\signalr\"/>
</ItemGroup>
Expand Down
3 changes: 3 additions & 0 deletions PluginBuilder/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
if (!verbose)
builder.Logging.AddFilter("Events", LogLevel.Warning);

builder.Services.AddHealthChecks().AddCheck<HealthService>("Dependencies");

// Uncomment this to see EF queries
// builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Trace);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information);
Expand All @@ -101,7 +103,7 @@
// 2. PB_ALLOWEDHOSTS env var restricting accepted hostnames via HostFilteringMiddleware
// https://github.com/btcpayserver/btcpayserver-plugin-builder-infra/pull/2
ForwardedHeadersOptions forwardingOptions = new() { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto };
forwardingOptions.KnownNetworks.Clear();

Check warning on line 106 in PluginBuilder/Program.cs

View workflow job for this annotation

GitHub Actions / test

'ForwardedHeadersOptions.KnownNetworks' is obsolete: 'Please use KnownIPNetworks instead. For more information, visit https://aka.ms/aspnet/deprecate/005.' (https://aka.ms/aspnet/deprecate/005)
forwardingOptions.KnownProxies.Clear();
forwardingOptions.ForwardedHeaders = ForwardedHeaders.All;
app.UseForwardedHeaders(forwardingOptions);
Expand Down Expand Up @@ -192,6 +194,7 @@
services.AddSingleton<AzureStorageClient>();
services.AddSingleton<ServerEnvironment>();
services.AddSingleton<EventAggregator>();
services.AddSingleton<HealthService>();
services.AddHttpClient(HttpClientNames.GitHub, client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
Expand All @@ -212,11 +215,11 @@
services.AddTransient<UserVerifiedLogic>();
services.AddScoped<ReferrerNavigationService>();
services.AddHttpContextAccessor();
services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();

Check warning on line 218 in PluginBuilder/Program.cs

View workflow job for this annotation

GitHub Actions / test

'ActionContextAccessor' is obsolete: 'ActionContextAccessor is obsolete and will be removed in a future version. For more information, visit https://aka.ms/aspnet/deprecate/006.' (https://aka.ms/aspnet/deprecate/006)

Check warning on line 218 in PluginBuilder/Program.cs

View workflow job for this annotation

GitHub Actions / test

'IActionContextAccessor' is obsolete: 'IActionContextAccessor is obsolete and will be removed in a future version. For more information, visit https://aka.ms/aspnet/deprecate/006.' (https://aka.ms/aspnet/deprecate/006)
services.AddScoped<IUrlHelper>(sp =>
{
var actionContext = sp.GetRequiredService<IActionContextAccessor>().ActionContext;

Check warning on line 221 in PluginBuilder/Program.cs

View workflow job for this annotation

GitHub Actions / test

'IActionContextAccessor' is obsolete: 'IActionContextAccessor is obsolete and will be removed in a future version. For more information, visit https://aka.ms/aspnet/deprecate/006.' (https://aka.ms/aspnet/deprecate/006)
return new UrlHelper(actionContext);

Check warning on line 222 in PluginBuilder/Program.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'actionContext' in 'UrlHelper.UrlHelper(ActionContext actionContext)'.
});
services.AddScoped<PluginOwnershipService>();
services.AddScoped<VersionLifecycleService>();
Expand Down
16 changes: 16 additions & 0 deletions PluginBuilder/Services/AzureStorageClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ public async Task<bool> EnsureDefaultContainerExists(CancellationToken cancellat
return ToJson(output)["created"]!.Value<bool>();
}

public async Task<bool> IsDefaultContainerAccessible(CancellationToken cancellationToken = default)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));

var container = blobClient.GetContainerReference(DefaultContainer);
return await container.ExistsAsync(null, null, cts.Token);
}
catch
{
return false;
}
}

public async Task<string> UploadImageFile(IFormFile file, string blobName)
{
var container = blobClient.GetContainerReference(DefaultContainer);
Expand Down
74 changes: 74 additions & 0 deletions PluginBuilder/Services/HealthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Npgsql;

namespace PluginBuilder.Services;

public class HealthService : IHealthCheck
{
public HealthService(DBConnectionFactory dbConnectionFactory, AzureStorageClient azureStorageClient, ProcessRunner processRunner, IHostApplicationLifetime lifetime)
{
DbConnectionFactory = dbConnectionFactory;
AzureStorageClient = azureStorageClient;
ProcessRunner = processRunner;

_lifetime = lifetime;
}

private readonly IHostApplicationLifetime _lifetime;

private DBConnectionFactory DbConnectionFactory { get; }
private AzureStorageClient AzureStorageClient { get; }
private ProcessRunner ProcessRunner { get; }

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (!_lifetime.ApplicationStarted.IsCancellationRequested)
return HealthCheckResult.Unhealthy("Startup incomplete");

var dbTask = IsDatabaseHealthy(cancellationToken);
var dockerTask = IsDockerHealthy(cancellationToken);
var azureTask = AzureStorageClient.IsDefaultContainerAccessible(cancellationToken);

await Task.WhenAll(dbTask, dockerTask, azureTask);

var isHealthy = dbTask.Result && dockerTask.Result && azureTask.Result;

return isHealthy
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy("Critical dependency unavailable");
}

private async Task<bool> IsDatabaseHealthy(CancellationToken cancellationToken)
{
try
{
await using var conn = await DbConnectionFactory.Open(cancellationToken);
await using var cmd = new NpgsqlCommand("SELECT 1", conn);
var result = await cmd.ExecuteScalarAsync(cancellationToken);
return result is 1;
}
catch
{
return false;
}
}

private async Task<bool> IsDockerHealthy(CancellationToken cancellationToken)
{
try
{
var code = await ProcessRunner.RunAsync(new ProcessSpec
{
Executable = "docker",
Arguments = ["info", "--format", "{{ .ServerVersion }}"],
OutputCapture = new OutputCapture(),
ErrorCapture = new OutputCapture()
}, cancellationToken);
return code == 0;
}
catch
{
return false;
}
}
}
8 changes: 8 additions & 0 deletions PluginBuilder/ViewModels/Home/HealthCheckViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace PluginBuilder.ViewModels.Home;

public class HealthCheckViewModel
{
public string Healthy { get; set; } = string.Empty;

public string Description { get; set; } = string.Empty;
}
Loading
Loading