Smoke-test /health for the reachable (200) and unreachable (503) cases
This commit is contained in:
1 parent
c67c71ea28
commit
666614a3b2
4 files changed
+110
-28
No files matched your search
@@ -108,6 +108,43 @@ public sealed class PocketBaseClient(HttpClient httpClient)
|
|||||||
public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default)
|
public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default)
|
||||||
=> GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic");
|
=> GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether PocketBase answers its health endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Backs the app's own <c>/health</c> endpoint, which an external monitor (Uptime
|
||||||
|
/// Kuma) polls: the app answering at all proves the app is up, and this probe adds
|
||||||
|
/// whether the content source behind it is reachable, so one monitor covers both.
|
||||||
|
/// This calls <c>GET /api/health</c>, PocketBase's own liveness endpoint, which
|
||||||
|
/// needs no auth and touches no collection.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="token">
|
||||||
|
/// A token to cancel the probe.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> if PocketBase answered with a success status within the
|
||||||
|
/// client timeout; <see langword="false"/> if it was unreachable, timed out, or
|
||||||
|
/// answered with an error status.
|
||||||
|
/// </returns>
|
||||||
|
public async Task<bool> IsHealthyAsync(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await httpClient.GetAsync(
|
||||||
|
new Uri("/api/health", UriKind.Relative), token);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
// Same failure shapes as a record read: unreachable (HttpRequestException) or the
|
||||||
|
// client's own timeout (a TaskCanceledException wrapping a TimeoutException). A
|
||||||
|
// plain TaskCanceledException is the caller's cancellation and propagates.
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is HttpRequestException
|
||||||
|
or TaskCanceledException { InnerException: TimeoutException })
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all public records of a collection in a single request.
|
/// Gets all public records of a collection in a single request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
@@ -140,4 +139,34 @@ public sealed class RouteSmokeTests : IDisposable
|
|||||||
|
|
||||||
html.ShouldContain("Förderverein"); // the page body, rendered from Markdown
|
html.ShouldContain("Förderverein"); // the page body, rendered from Markdown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_returns_200_when_PocketBase_is_reachable()
|
||||||
|
{
|
||||||
|
// The monitor's happy path: the app answers and PocketBase (the seeded
|
||||||
|
// fixture) is up, so /health is 200. This mostly guards the wiring -- that the
|
||||||
|
// endpoint exists and reaches the client -- since the fixture keeps PocketBase
|
||||||
|
// alive; the unreachable case is covered separately below.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_returns_503_when_PocketBase_is_unreachable()
|
||||||
|
{
|
||||||
|
// The case the monitor exists for: the app is up but PocketBase is not. Point a
|
||||||
|
// throwaway app at a dead address (nothing listens on port 1, so the probe is
|
||||||
|
// refused at once, no timeout wait) and assert /health reports 503 rather than
|
||||||
|
// claiming healthy.
|
||||||
|
await using var factory = _baseFactory.WithWebHostBuilder(builder =>
|
||||||
|
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+31
-21
@@ -6,52 +6,50 @@ using Microsoft.AspNetCore.HttpOverrides;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
|
||||||
builder.Services.AddRazorComponents();
|
builder.Services.AddRazorComponents();
|
||||||
|
|
||||||
// The typed client reads content from PocketBase over its REST API. The base URL
|
// The typed client reads content from PocketBase over its REST API. The base URL
|
||||||
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl
|
// comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
|
||||||
// env var on the server (see compose.yaml).
|
// on the server (see compose.yaml).
|
||||||
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
|
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
|
||||||
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
|
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
|
||||||
builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
||||||
client.BaseAddress = new Uri(pocketBaseUrl));
|
{
|
||||||
|
client.BaseAddress = new Uri(pocketBaseUrl);
|
||||||
|
// Every page waits for PocketBase. The default wait is 100 seconds, so if
|
||||||
|
// PocketBase is down the page would hang that long. 5 seconds fails fast instead.
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// NPM terminates TLS and is the only way to reach the container (no port
|
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
||||||
// mapping in production). Without UseForwardedHeaders the app sees every request
|
// think every request is plain HTTP and would see NPM's IP, not the visitor's.
|
||||||
// as HTTP and with the proxy IP instead of the client IP.
|
// KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app.
|
||||||
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
|
|
||||||
// the container.
|
|
||||||
app.UseForwardedHeaders(
|
app.UseForwardedHeaders(
|
||||||
new ForwardedHeadersOptions
|
new ForwardedHeadersOptions
|
||||||
{
|
{
|
||||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
|
||||||
if (app.Environment.IsDevelopment() is false)
|
if (app.Environment.IsDevelopment() is false)
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates
|
// No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS.
|
||||||
// TLS. Both here would create a redirect loop behind the proxy.
|
// Adding them behind NPM would send the browser into a redirect loop.
|
||||||
}
|
}
|
||||||
|
|
||||||
// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage
|
// Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own
|
||||||
// (Pages.NotFound) in process for both an unmatched route and an explicit
|
// not-found page. That middleware would catch *every* error code, so when PocketBase
|
||||||
// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing
|
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
||||||
// every error code to /not-found would also turn a 503 (content source down) into
|
|
||||||
// a "not found" page; leaving it out lets those components keep their own
|
|
||||||
// "temporarily unavailable" markup and 503 status.
|
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorComponents<App>();
|
app.MapRazorComponents<App>();
|
||||||
|
|
||||||
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
// Calendar feed people can subscribe to. It returns calendar data, not a web page,
|
||||||
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase
|
// so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an
|
||||||
// per request; an unreachable source yields an empty calendar, not a 500.
|
// empty calendar rather than an error.
|
||||||
app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
||||||
{
|
{
|
||||||
IReadOnlyList<Event> events;
|
IReadOnlyList<Event> events;
|
||||||
@@ -67,4 +65,16 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken
|
|||||||
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Status page for an outside monitor (Uptime Kuma) to check. One check tells all
|
||||||
|
// three states apart: no reply = the app is down, 503 = the app runs but PocketBase
|
||||||
|
// is down, 200 = both are fine. It talks to PocketBase directly, not through the
|
||||||
|
// page-rendering code, so it can still report when that code is the thing failing.
|
||||||
|
app.MapGet("/health", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
||||||
|
await pocketBase.IsHealthyAsync(token)
|
||||||
|
? Results.Text("healthy", "text/plain; charset=utf-8")
|
||||||
|
: Results.Text(
|
||||||
|
"PocketBase unreachable",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable));
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
+12
-6
@@ -3,8 +3,12 @@
|
|||||||
#
|
#
|
||||||
# The two `ports:` blocks below are TEMPORARY. The target state is NPM in front:
|
# The two `ports:` blocks below are TEMPORARY. The target state is NPM in front:
|
||||||
# NPM terminates TLS and is the only path to the web app, and the editors reach
|
# NPM terminates TLS and is the only path to the web app, and the editors reach
|
||||||
# PocketBase through an NPM subdomain -- then both port mappings go away (#9). While
|
# PocketBase through an NPM subdomain (#9). NPM still needs a target address and
|
||||||
# NPM is not set up yet, the ports are the only way onto the site and the admin UI.
|
# port, so a port stays reachable either way -- the plan is to reach the containers
|
||||||
|
# by name over a shared Docker network (e.g. eb-blazor:8080) instead of publishing
|
||||||
|
# them on the LAN, and to drop these host mappings that expose the site and admin UI
|
||||||
|
# to anyone on the LAN. While NPM is not set up yet, these mappings are the only way
|
||||||
|
# onto the site and the admin UI.
|
||||||
#
|
#
|
||||||
# For local development use the dev overlay on top, which adds host ports and builds
|
# For local development use the dev overlay on top, which adds host ports and builds
|
||||||
# the web image from source:
|
# the web image from source:
|
||||||
@@ -31,8 +35,9 @@ services:
|
|||||||
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
||||||
ports:
|
ports:
|
||||||
# TEMPORARY: exposes the admin UI on the LAN (http://<unraid>:8090/_/) while
|
# TEMPORARY: exposes the admin UI on the LAN (http://<unraid>:8090/_/) while
|
||||||
# NPM is not in front yet. Remove once the editors reach PocketBase through an
|
# NPM is not in front yet. Replace once the editors reach PocketBase through an
|
||||||
# NPM subdomain (#9) -- until then the admin has no other way in.
|
# NPM subdomain (#9): NPM points at eb-pocketbase:8090 over a shared network, so
|
||||||
|
# this host mapping can go -- until then the admin has no other way in.
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# The image ships this endpoint; the web app waits for it to pass.
|
# The image ships this endpoint; the web app waits for it to pass.
|
||||||
@@ -55,6 +60,7 @@ services:
|
|||||||
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
||||||
ports:
|
ports:
|
||||||
# TEMPORARY: reach the site on the LAN (http://<unraid>:5000) while NPM is not
|
# TEMPORARY: reach the site on the LAN (http://<unraid>:5000) while NPM is not
|
||||||
# in front yet. Replace with the external npm network once NPM terminates TLS
|
# in front yet. Replace once NPM terminates TLS and fronts the site (#9): join
|
||||||
# and is the only path in (#9).
|
# the external NPM network and let NPM point at eb-blazor:8080 by name, so this
|
||||||
|
# host mapping can go and the site is no longer open on the LAN.
|
||||||
- "5000:8080"
|
- "5000:8080"
|
||||||
Reference in new issue
Block a user