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)
|
||||
=> 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>
|
||||
/// Gets all public records of a collection in a single request.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
@@ -140,4 +139,34 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
|
||||
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);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents();
|
||||
|
||||
// The typed client reads content from PocketBase over its REST API. The base URL
|
||||
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl
|
||||
// env var on the server (see compose.yaml).
|
||||
// comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
|
||||
// on the server (see compose.yaml).
|
||||
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
|
||||
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
|
||||
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();
|
||||
|
||||
// NPM terminates TLS and is the only way to reach the container (no port
|
||||
// mapping in production). Without UseForwardedHeaders the app sees every request
|
||||
// as HTTP and with the proxy IP instead of the client IP.
|
||||
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
|
||||
// the container.
|
||||
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
||||
// think every request is plain HTTP and would see NPM's IP, not the visitor's.
|
||||
// KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app.
|
||||
app.UseForwardedHeaders(
|
||||
new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
});
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment() is false)
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates
|
||||
// TLS. Both here would create a redirect loop behind the proxy.
|
||||
// No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS.
|
||||
// Adding them behind NPM would send the browser into a redirect loop.
|
||||
}
|
||||
|
||||
// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage
|
||||
// (Pages.NotFound) in process for both an unmatched route and an explicit
|
||||
// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing
|
||||
// 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.
|
||||
// Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own
|
||||
// not-found page. That middleware would catch *every* error code, so when PocketBase
|
||||
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>();
|
||||
|
||||
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
||||
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase
|
||||
// per request; an unreachable source yields an empty calendar, not a 500.
|
||||
// Calendar feed people can subscribe to. It returns calendar data, not a web page,
|
||||
// so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an
|
||||
// empty calendar rather than an error.
|
||||
app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
||||
{
|
||||
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");
|
||||
});
|
||||
|
||||
// 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();
|
||||
+12
-6
@@ -3,8 +3,12 @@
|
||||
#
|
||||
# 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
|
||||
# PocketBase through an NPM subdomain -- then both port mappings go away (#9). While
|
||||
# NPM is not set up yet, the ports are the only way onto the site and the admin UI.
|
||||
# PocketBase through an NPM subdomain (#9). NPM still needs a target address and
|
||||
# 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
|
||||
# the web image from source:
|
||||
@@ -31,8 +35,9 @@ services:
|
||||
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
||||
ports:
|
||||
# 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 subdomain (#9) -- until then the admin has no other way in.
|
||||
# NPM is not in front yet. Replace once the editors reach PocketBase through an
|
||||
# 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"
|
||||
healthcheck:
|
||||
# The image ships this endpoint; the web app waits for it to pass.
|
||||
@@ -55,6 +60,7 @@ services:
|
||||
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
||||
ports:
|
||||
# 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
|
||||
# and is the only path in (#9).
|
||||
# in front yet. Replace once NPM terminates TLS and fronts the site (#9): join
|
||||
# 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"
|
||||
Reference in new issue
Block a user