feat(ops): add database readiness probe
This commit is contained in:
@@ -645,6 +645,23 @@ app.MapGet("/health", (IConfiguration cfg) => Results.Ok(new
|
|||||||
version = BuildMetadata.ResolveVersion(cfg),
|
version = BuildMetadata.ResolveVersion(cfg),
|
||||||
})).AllowAnonymous();
|
})).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.
|
// API schema for tooling/docs. Dev-only: not exposed in production deployments.
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
|||||||
+6
-2
@@ -305,9 +305,13 @@ than merely "running".
|
|||||||
- **Backend:** `curl -fsS http://127.0.0.1:8080/health`. Anonymous, and deliberately **does not touch
|
- **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
|
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.
|
database blipped. `start_period` is 90s to cover first-boot schema reconciliation.
|
||||||
|
- **API/database readiness:** `curl -fsS https://<host>/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:** `wget` against nginx on port 80.
|
||||||
- `frontend` waits for `backend` to be *healthy*, not merely started, because nginx proxies `/api` to
|
- `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.
|
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
|
A backend that cannot reach its database during startup exits and is reported `unhealthy`. A dependency
|
||||||
present as a running stack.
|
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.
|
||||||
|
|||||||
@@ -550,6 +550,16 @@ if ! curl -fsS "${public_base}/" >/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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"
|
echo "Public API smoke check failed for ${public_base}/api/auth/config"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 `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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Focused frontend: 2 suites, 6 tests passed.
|
||||||
- Full frontend: 64 suites, 272 tests passed.
|
- Full frontend: 64 suites, 272 tests passed.
|
||||||
- Next production build and TypeScript: passed.
|
- Next production build and TypeScript: passed.
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en"));
|
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 }) => {
|
test("public plans stay honest, responsive and keyboard accessible", async ({ page }) => {
|
||||||
for (const scheme of ["light", "dark"] as const) {
|
for (const scheme of ["light", "dark"] as const) {
|
||||||
await page.addInitScript((value) => window.localStorage.setItem("jobtracker.themeMode", value), scheme);
|
await page.addInitScript((value) => window.localStorage.setItem("jobtracker.themeMode", value), scheme);
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
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/ {
|
location /api/ {
|
||||||
proxy_pass http://backend-web:8080;
|
proxy_pass http://backend-web:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
Reference in New Issue
Block a user