From 3ef2c061376a3754c95a6becda8a12b965632d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niko=20Ko=CC=88bler?= Date: Wed, 23 Sep 2026 21:39:23 +0200 Subject: [PATCH 1/3] feat: expose health endpoints for external monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An uptime monitor had nothing to poll but a page render, which costs a database round trip, a template render and an external feed lookup just to answer "is it up". quarkus-smallrye-health answers that directly, and quarkus-agroal contributes the datasource readiness check by itself, so no hand-written HealthCheck is needed. The endpoints stay anonymous on the main port so a monitor can reach them without a Keycloak client. That is exactly what makes them easy to break: with oidc.application-type=web-app, a probe path pulled into quarkus.http.auth.permission answers 302 to Keycloak instead of a status, which a monitor checking only for "a response" would happily accept. The test therefore refuses to follow redirects, and it asserts that readiness carries at least one check -- disabling the datasource check would leave a probe that reports UP while testing nothing. The events feed is deliberately left out: it is a third-party URL, and a readiness probe going red on someone else's hiccup would take an app out of rotation that still serves every page from the 5-minute cache. Signed-off-by: Niko Köbler --- pom.xml | 4 ++ .../jugda/registration/HealthCheckTest.java | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/test/java/de/jugda/registration/HealthCheckTest.java diff --git a/pom.xml b/pom.xml index 79cc41f..b2b7386 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,10 @@ io.quarkus quarkus-scheduler + + io.quarkus + quarkus-smallrye-health + io.quarkiverse.mailpit quarkus-mailpit diff --git a/src/test/java/de/jugda/registration/HealthCheckTest.java b/src/test/java/de/jugda/registration/HealthCheckTest.java new file mode 100644 index 0000000..ae9f13b --- /dev/null +++ b/src/test/java/de/jugda/registration/HealthCheckTest.java @@ -0,0 +1,50 @@ +package de.jugda.registration; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; + +/** + * The health endpoints are the one part of the app that is polled by machines instead of people, + * so the two ways they break silently are pinned down here: a redirect instead of a status (OIDC + * runs in web-app mode, where an accidentally secured path answers a probe with a 302 to Keycloak + * that a naive monitor may even count as "reachable"), and a readiness check that reports UP + * without checking anything. + * + * @author Niko Köbler, https://www.n-k.de, @dasniko + */ +@QuarkusTest +public class HealthCheckTest { + + @Test + void theHealthEndpointsAnswerAnonymouslyAndWithoutARedirect() { + for (String path : new String[]{"/q/health", "/q/health/live", "/q/health/ready", "/q/health/started"}) { + given() + .redirects().follow(false) + .when() + .get(path) + .then() + .statusCode(200) + .body("status", equalTo("UP")); + } + } + + @Test + void readinessActuallyChecksTheDatabase() { + given() + .when() + .get("/q/health/ready") + .then() + .statusCode(200) + // The check is contributed by quarkus-agroal, not by this code base, and disabling it + // (quarkus.datasource.health.enabled=false) would leave a readiness probe that reports + // UP while testing nothing. Its name is a SmallRye-internal string, so only its + // presence is asserted, not its wording. + .body("checks", hasSize(greaterThanOrEqualTo(1))) + .body("checks[0].status", equalTo("UP")); + } +} From caf85d434d4aae1fd8f1a121ab67d93a58f7cfd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niko=20Ko=CC=88bler?= Date: Wed, 23 Sep 2026 21:39:32 +0200 Subject: [PATCH 2/3] build: wait for a healthy database instead of a started one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit depends_on alone only waits for the container to exist, not for Postgres to accept connections, so the app could come up first and have Flyway fail its migration at start against a database that was not listening yet. A pg_isready healthcheck plus condition: service_healthy closes that race. The app gets a healthcheck of its own against /q/health/ready, which is what makes "unhealthy" visible in docker ps. Compose does not restart an unhealthy container by itself -- this buys ordering and visibility, not self-healing. curl is used because the ubi9/openjdk-25-runtime image ships it and has no wget. Signed-off-by: Niko Köbler --- docker-compose.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 949fcb3..64d9c6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,12 +6,20 @@ services: POSTGRES_PASSWORD: Rg1str4t10n ports: - "5432:5432" + healthcheck: + # POSTGRES_DB is unset, so the database is named after POSTGRES_USER. + test: ["CMD-SHELL", "pg_isready -U registration -d registration"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s restart: always application: image: ghcr.io/ijug-ev/registration:latest depends_on: - - database + database: + condition: service_healthy environment: QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://database:5432/registration QUARKUS_DATASOURCE_USERNAME: registration @@ -24,4 +32,10 @@ services: QUARKUS_OIDC_CREDENTIALS_SECRET: changeme ports: - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/q/health/ready"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s restart: always From c7d3df4455ca8e9bb33bcdc71b66da06618dbf23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niko=20Ko=CC=88bler?= Date: Wed, 23 Sep 2026 21:39:32 +0200 Subject: [PATCH 3/3] docs: record why the health checks are shaped the way they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two decisions that are invisible in the diff: the endpoints must stay out of quarkus.http.auth.permission (a secured probe answers 302, not a status), and the external events feed is intentionally not part of readiness. closes #61 Signed-off-by: Niko Köbler --- CLAUDE.md | 25 +++++++++++++++++++++++++ README.adoc | 12 ++++++++++++ 2 files changed, 37 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b06bab3..d438585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ Two things to know when the next LTS arrives: - Admin UI: http://localhost:8080/admin/test/events (credentials: `alice` / `alice`) - Mailpit UI: http://localhost:8080/q/dev-ui/quarkus-mailpit/mailpit-ui - Keycloak dev server: http://localhost:8081 +- Health: http://localhost:8080/q/health ## Architecture @@ -91,6 +92,30 @@ creates the client role of the same name by hand. `Accept` header gets the page -- without it the runtime picks whichever method it discovered first, which is not stable across JVM runs and failed the menu test in roughly one build in eight. +### Health Endpoints + +`quarkus-smallrye-health` exposes `/q/health`, `/q/health/live`, `/q/health/ready` and +`/q/health/started` (issue #61). There is **no hand-written `HealthCheck` in this code base**: the +single check that shows up is the datasource readiness check `quarkus-agroal` contributes by itself. + +- **The external events feed is deliberately not a health check.** `EventService` loads each tenant's + events JSON from a third-party URL. A readiness probe that went red when someone else's server + hiccups would pull a working app out of rotation, while the registration pages keep serving from the + 5-minute Caffeine cache. Feed failures belong in the log. +- **The endpoints are anonymous and sit on the main port**, which is what lets an external uptime + monitor reach them. They therefore have to stay out of `quarkus.http.auth.permission`: with + `quarkus.oidc.application-type=web-app` a secured probe path answers **302 to Keycloak**, and a + monitor that only asks "did I get a response" never notices. `HealthCheckTest` pins that down + (`redirects().follow(false)`), plus the fact that readiness carries at least one check -- without + that, `quarkus.datasource.health.enabled=false` would leave a probe reporting UP while testing + nothing. + +`docker-compose.yml` consumes them: `pg_isready` on the database, `curl -f .../q/health/ready` on the +app (the `ubi9/openjdk-25-runtime` image has `curl`, but no `wget`), and the app now waits for +`condition: service_healthy` instead of a bare `depends_on: database` -- which used to let Flyway race +the database's first accepting connection. Compose does not restart an unhealthy container on its own; +the healthcheck buys ordering and visibility, not self-healing. + ### Key Components - **`RegistrationResource`** — handles registration form display and submission; decides between `registration`, `closed`, and `not_yet_open` templates based on deadline / `opensBeforeInMonths`. diff --git a/README.adoc b/README.adoc index 89c1eb0..bbcec33 100644 --- a/README.adoc +++ b/README.adoc @@ -36,6 +36,7 @@ TIP: You can now change classes, configurations and other resources on the fly, * Registration: http://localhost:8080/registration/test?eventId=2026-12-31&opensBeforeInMonths=8 * Admin: http://localhost:8080/admin/test/events (User: alice/alice) * Mailpit: http://localhost:8080/q/dev-ui/quarkus-mailpit/mailpit-ui +* Health: http://localhost:8080/q/health For runnint http requests manually, see link:misc/run-http-requests-manually.adoc[here]. @@ -58,3 +59,14 @@ include::docker-compose.yml[] ---- TIP: The production app should be available directly under https://registration.ijug.eu + +== Monitoring + +The app exposes the standard MicroProfile Health endpoints, unauthenticated, on the regular port: + +* `/q/health/live` -- the process is alive +* `/q/health/ready` -- alive *and* the database is reachable +* `/q/health/started` -- startup has finished +* `/q/health` -- all of the above in one response + +An uptime monitor should poll `/q/health/ready`; that is also what the Docker Compose healthcheck above uses.