From b9e703ee7c132eba9a4f924fd9569ccc8376ebad Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 11:54:36 +0200 Subject: [PATCH] feat(ops): add database readiness probe --- JobTrackerApi/Program.cs | 17 +++++++++++++++++ deploy/README.md | 8 ++++++-- deploy/deploy.sh | 10 ++++++++++ docs/work-programmes/master-progress.md | 2 ++ job-tracker-ui/e2e/smoke.spec.ts | 11 +++++++++++ job-tracker-ui/nginx.conf.template | 10 ++++++++++ 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index ef904d4..8a03229 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -645,6 +645,23 @@ app.MapGet("/health", (IConfiguration cfg) => Results.Ok(new version = BuildMetadata.ResolveVersion(cfg), })).AllowAnonymous(); +// Readiness is separate from liveness: dependency outages must block a release without causing +// Docker to restart an otherwise healthy API process. Keep the public response deliberately terse; +// authorised administrators get detailed database diagnostics from /api/admin/system. +app.MapGet("/ready", async (JobTrackerContext db, CancellationToken cancellationToken) => +{ + try + { + return await db.Database.CanConnectAsync(cancellationToken) + ? Results.Ok(new { status = "ready" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable); + } + catch + { + return Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable); + } +}).AllowAnonymous(); + // API schema for tooling/docs. Dev-only: not exposed in production deployments. if (app.Environment.IsDevelopment()) { diff --git a/deploy/README.md b/deploy/README.md index 7656fc1..80188d7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -305,9 +305,13 @@ than merely "running". - **Backend:** `curl -fsS http://127.0.0.1:8080/health`. Anonymous, and deliberately **does not touch the database** — a health check that queried MariaDB would restart a healthy backend whenever the database blipped. `start_period` is 90s to cover first-boot schema reconciliation. +- **API/database readiness:** `curl -fsS https:///ready`. This checks that the API can reach its + configured database and returns only `ready` or `unavailable`; detailed diagnostics remain admin-only. + Deployment validation requires this probe, while Docker restart policy continues to use liveness. - **Frontend:** `wget` against nginx on port 80. - `frontend` waits for `backend` to be *healthy*, not merely started, because nginx proxies `/api` to it and refuses to start if the upstream cannot be resolved. -A backend that cannot reach its database exits and is reported `unhealthy`, so a broken deploy does not -present as a running stack. +A backend that cannot reach its database during startup exits and is reported `unhealthy`. A dependency +failure after startup leaves the process alive but makes `/ready` return 503, so deployment validation +cannot mistake a live-but-unready stack for a successful release. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index e250c34..d991b1d 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -550,6 +550,16 @@ if ! curl -fsS "${public_base}/" >/dev/null; then exit 1 fi +if ! curl -fsS "${public_base}/health" >/dev/null; then + echo "Public API liveness check failed for ${public_base}/health" + exit 1 +fi + +if ! curl -fsS "${public_base}/ready" >/dev/null; then + echo "Public API/database readiness check failed for ${public_base}/ready" + exit 1 +fi + if ! curl -fsS -D "$auth_config_headers_file" -o "$auth_config_body_file" "${public_base}/api/auth/config"; then echo "Public API smoke check failed for ${public_base}/api/auth/config" exit 1 diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index a07bd6a..c96f4b1 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -49,6 +49,7 @@ Updated: 2026-08-30 - Rebuilt the active developer/operator documentation around the actual Next.js 16/.NET 9 application, replaced CRA and `npm start` guidance, separated normal and Playwright ports, corrected React Router 7 and the SQLite/MariaDB provider matrix, removed the obsolete npm peer override, and verified the documented clean install, lint, test, build and locked-restore commands. - Began the JT-019 schema-ownership retirement with an executable 49-table ownership partition and transferred the leaf `SystemEmailSettings` table from MariaDB-only startup DDL to an additive provider-aware migration. Fresh SQLite now receives the table; legacy rows are preserved and startup no longer creates it. - Continued the job-application controller split by moving canonical pipeline metadata, soft delete/restore, follow-up scheduling, and event history into `JobApplicationLifecycleController` without changing routes, authorization, tenant filtering, response shapes, or event behavior. Status mutation remains with core updates until its shared applied-date invariant has a single service owner. +- Added a minimal public `/ready` dependency probe alongside the existing `/health` liveness probe. Nginx exposes both, deployment validation now checks the frontend, API liveness, database readiness, and public auth configuration separately, while detailed dependency metadata remains restricted to Admin/System. - Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry. - Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path. - Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry. @@ -94,6 +95,7 @@ Updated: 2026-08-30 - Post-cleanup frontend: ESLint passed with zero warnings, all 60 suites and 260/260 tests passed, optimized Next production build and integrated TypeScript passed, and Playwright passed 10/10 including public and long searchable multi-page CV PDFs. - Analytics-controller extraction: Release build passed with 0 warnings/errors and the complete backend suite passed 736/736. - Lifecycle-controller extraction: locked restore passed, Release build passed with 0 warnings/errors, and the complete backend suite passed 736/736. +- Health/readiness split: Release build passed with 0 warnings/errors, the complete backend suite passed 736/736, and the focused Playwright probe passed 1/1 against a real disposable SQLite-backed API process. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. diff --git a/job-tracker-ui/e2e/smoke.spec.ts b/job-tracker-ui/e2e/smoke.spec.ts index bc32865..3db8f56 100644 --- a/job-tracker-ui/e2e/smoke.spec.ts +++ b/job-tracker-ui/e2e/smoke.spec.ts @@ -57,6 +57,17 @@ test.beforeEach(async ({ page }) => { await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en")); }); +test("API liveness and database readiness are separate public probes", async ({ request }) => { + const origin = apiUrl.replace(/\/api$/, ""); + const liveness = await request.get(`${origin}/health`); + const readiness = await request.get(`${origin}/ready`); + + expect(liveness.status()).toBe(200); + expect(await liveness.json()).toMatchObject({ status: "ok" }); + expect(readiness.status()).toBe(200); + expect(await readiness.json()).toEqual({ status: "ready" }); +}); + test("public plans stay honest, responsive and keyboard accessible", async ({ page }) => { for (const scheme of ["light", "dark"] as const) { await page.addInitScript((value) => window.localStorage.setItem("jobtracker.themeMode", value), scheme); diff --git a/job-tracker-ui/nginx.conf.template b/job-tracker-ui/nginx.conf.template index fc0396d..11d0193 100644 --- a/job-tracker-ui/nginx.conf.template +++ b/job-tracker-ui/nginx.conf.template @@ -41,6 +41,16 @@ server { proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + location = /ready { + proxy_pass http://backend-web:8080/ready; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $http_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + location /api/ { proxy_pass http://backend-web:8080; proxy_http_version 1.1;