[E00-S03-T06] App does not report ready before migrations complete #395
+27
-5
@@ -5,8 +5,12 @@
|
|||||||
# [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the
|
# [E00-S02-T01/T02/T03] baseline: builds the workspace server package with the
|
||||||
# pinned toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the
|
# pinned toolchain (Node 24.19.0 + pnpm 11.23.0, frozen lockfile) and runs the
|
||||||
# compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http`
|
# compiled entrypoint. Since T03 the entrypoint is a minimal Node `node:http`
|
||||||
# server answering `GET /health` with `{"status":"ok"}` (HTTP 200) on port
|
# server answering `GET /health` on port 3000. Since T06 (E00-S03) the health
|
||||||
# 3000, so the app container stays up and the health endpoint succeeds. The
|
# endpoint is the readiness probe: the app runs the startup migrations (the
|
||||||
|
# `MigrationRunner` from `@personal-blog/database-postgres`) before reporting
|
||||||
|
# ready — `GET /health` answers 503 `{"status":"not ready"}` while the run is
|
||||||
|
# in flight and flips to 200 `{"status":"ok"}` only after it completes — so
|
||||||
|
# the app container does not report ready before migrations complete. The
|
||||||
# Fastify 5 application shell (and the real HTTP API) lands in a later story;
|
# Fastify 5 application shell (and the real HTTP API) lands in a later story;
|
||||||
# DB volume persistence (T04), read-only root filesystem (T06) and multi-arch
|
# DB volume persistence (T04), read-only root filesystem (T06) and multi-arch
|
||||||
# build targets (T07) are Compose-level concerns (see compose.yaml — the
|
# build targets (T07) are Compose-level concerns (see compose.yaml — the
|
||||||
@@ -17,6 +21,15 @@
|
|||||||
# T05 the runtime stage drops root privileges (runs as the image's non-root
|
# T05 the runtime stage drops root privileges (runs as the image's non-root
|
||||||
# `node` user).
|
# `node` user).
|
||||||
#
|
#
|
||||||
|
# The app now depends on the `database-postgres` workspace package (the single
|
||||||
|
# owner of the pg/Kysely driver, E00-S03-T02). The build stage therefore also
|
||||||
|
# installs/builds that package — the server's `build`/`typecheck` scripts
|
||||||
|
# build their workspace dependency first (`pnpm --filter
|
||||||
|
# @personal-blog/database-postgres build`), and the runtime stage ships the
|
||||||
|
# compiled `packages/database-postgres/dist` next to the copied workspace
|
||||||
|
# node_modules links so the server's `@personal-blog/database-postgres` import
|
||||||
|
# resolves at run time.
|
||||||
|
#
|
||||||
# T08: the image embeds no secrets. The Dockerfile declares no secret-bearing
|
# T08: the image embeds no secrets. The Dockerfile declares no secret-bearing
|
||||||
# ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY
|
# ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY
|
||||||
# copies a fixed, non-secret path (manifests, source, compiled dist) — never
|
# copies a fixed, non-secret path (manifests, source, compiled dist) — never
|
||||||
@@ -44,15 +57,21 @@ RUN corepack enable
|
|||||||
# invalidate the dependency layer, then install against the committed lockfile
|
# invalidate the dependency layer, then install against the committed lockfile
|
||||||
# (the same `--frozen-lockfile` path CI and developers use). Every workspace
|
# (the same `--frozen-lockfile` path CI and developers use). Every workspace
|
||||||
# package manifest is copied so the in-image workspace matches the lockfile
|
# package manifest is copied so the in-image workspace matches the lockfile
|
||||||
# importers exactly (apps/server, packages/core, extensions/example).
|
# importers exactly (apps/server, packages/core, packages/database-postgres,
|
||||||
|
# extensions/example).
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY packages/core/package.json packages/core/package.json
|
COPY packages/core/package.json packages/core/package.json
|
||||||
|
COPY packages/database-postgres/package.json packages/database-postgres/package.json
|
||||||
COPY extensions/example/package.json extensions/example/package.json
|
COPY extensions/example/package.json extensions/example/package.json
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/).
|
# Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The
|
||||||
|
# server's build script builds its workspace dependency first (the
|
||||||
|
# `database-postgres` package, whose compiled dist the server imports), so a
|
||||||
|
# single command produces both dists in the right order.
|
||||||
COPY apps/server apps/server
|
COPY apps/server apps/server
|
||||||
|
COPY packages/database-postgres packages/database-postgres
|
||||||
RUN pnpm --filter @personal-blog/server build
|
RUN pnpm --filter @personal-blog/server build
|
||||||
|
|
||||||
# --- runtime stage: Node 24.19.0 (bookworm-slim) + compiled output only ------
|
# --- runtime stage: Node 24.19.0 (bookworm-slim) + compiled output only ------
|
||||||
@@ -61,10 +80,13 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# The workspace install (devDependencies included — image-size pruning is a
|
# The workspace install (devDependencies included — image-size pruning is a
|
||||||
# later E00-S02 concern) plus the compiled server output and manifest.
|
# later E00-S02 concern) plus the compiled server output, the compiled
|
||||||
|
# database-postgres output the server imports, and the package manifests.
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/server/dist ./apps/server/dist
|
COPY --from=build /app/apps/server/dist ./apps/server/dist
|
||||||
COPY --from=build /app/apps/server/package.json ./apps/server/package.json
|
COPY --from=build /app/apps/server/package.json ./apps/server/package.json
|
||||||
|
COPY --from=build /app/packages/database-postgres/dist ./packages/database-postgres/dist
|
||||||
|
COPY --from=build /app/packages/database-postgres/package.json ./packages/database-postgres/package.json
|
||||||
|
|
||||||
# T05: run as the image's non-root `node` user (uid/gid 1000, shipped with the
|
# T05: run as the image's non-root `node` user (uid/gid 1000, shipped with the
|
||||||
# official Node image) so the app container does not run with root privileges.
|
# official Node image) so the app container does not run with root privileges.
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story.",
|
"description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06); the Fastify 5 application shell lands in a later story.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@personal-blog/database-postgres": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.13.3"
|
"@types/node": "24.13.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,26 +2,60 @@
|
|||||||
* @personal-blog/server — EPPP public server application.
|
* @personal-blog/server — EPPP public server application.
|
||||||
*
|
*
|
||||||
* [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
|
* [E00-S02-T03] minimal serving process: a small HTTP server built on Node's
|
||||||
* `node:http` (no runtime dependencies yet) that answers the application
|
* `node:http` that answers the application health endpoint. `GET /health`
|
||||||
* health endpoint. `GET /health` reports a healthy application — HTTP 200 with
|
* reports the application readiness — HTTP 200 with `{"status":"ok"}` — so
|
||||||
* `{"status":"ok"}` — so the Compose stack's `app` service stays up and the
|
* the Compose stack's `app` service stays up and the health endpoint
|
||||||
* health endpoint succeeds once the stack is running.
|
* succeeds once the stack is running.
|
||||||
|
*
|
||||||
|
* [E00-S03-T06] readiness gate: the app does **not** report ready before
|
||||||
|
* migrations complete. When a `DATABASE_URL` is configured, the server runs
|
||||||
|
* the startup migrations (the `MigrationRunner` from
|
||||||
|
* `@personal-blog/database-postgres` over the migration ledger, E00-S03-T03)
|
||||||
|
* before it reports ready: `GET /health` answers HTTP 503 with
|
||||||
|
* `{"status":"not ready"}` while the migration run is in flight, and flips to
|
||||||
|
* HTTP 200 `{"status":"ok"}` only after the run finishes. When no
|
||||||
|
* `DATABASE_URL` is configured (e.g. the local non-container developer path,
|
||||||
|
* E00-S01-T06) there are no migrations to run, so the app reports ready
|
||||||
|
* immediately.
|
||||||
*
|
*
|
||||||
* The Fastify 5 application shell (and the real HTTP API) lands in a later
|
* The Fastify 5 application shell (and the real HTTP API) lands in a later
|
||||||
* story; this bootstrap keeps the application health-checkable until then.
|
* story; this bootstrap keeps the application health-checkable until then.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { Pool } from '@personal-blog/database-postgres';
|
||||||
|
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
||||||
|
import type { Migration } from '@personal-blog/database-postgres';
|
||||||
|
|
||||||
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
/** Port the server listens on; `PORT` overrides the container default (3000). */
|
||||||
const PORT = resolvePort(process.env.PORT);
|
const PORT = resolvePort(process.env.PORT);
|
||||||
|
|
||||||
/** Health payload — reports a healthy application. */
|
/** Health payload — reported once the startup migration run completes. */
|
||||||
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
|
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
|
||||||
|
|
||||||
|
/** Payload while the startup migration run is still in flight — the app is up but NOT ready. */
|
||||||
|
const NOT_READY_PAYLOAD = JSON.stringify({ status: 'not ready' });
|
||||||
|
|
||||||
/** Payload for any route that is not the health endpoint. */
|
/** Payload for any route that is not the health endpoint. */
|
||||||
const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' });
|
const NOT_FOUND_PAYLOAD = JSON.stringify({ error: 'not found' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The migrations the server applies at startup, oldest first. Empty until the
|
||||||
|
* first schema migration lands (E00-S03 is the migration-runner foundation;
|
||||||
|
* real schema migrations arrive with the domain stories). The runner still
|
||||||
|
* ensures the migration ledger (E00-S03-T03) exists and reads the applied
|
||||||
|
* versions, so even an empty run is a real, observable migration step that
|
||||||
|
* readiness waits for.
|
||||||
|
*/
|
||||||
|
const MIGRATIONS: readonly Migration[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readiness state — false until the startup migration run completes. The app
|
||||||
|
* is "up" (the HTTP server is listening) but reports not-ready (E00-S03-T06)
|
||||||
|
* while migrations are pending.
|
||||||
|
*/
|
||||||
|
let migrationsComplete = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
|
* Resolves the listen port from `PORT` (default 3000, matching the Dockerfile
|
||||||
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
|
* `EXPOSE 3000` and the compose `:3000` container port). A non-numeric or
|
||||||
@@ -44,11 +78,17 @@ function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes one request. The application only serves the health endpoint at this
|
* Routes one request. The application only serves the health endpoint at this
|
||||||
* stage; anything else is a 404 so misconfiguration is loud.
|
* stage; anything else is a 404 so misconfiguration is loud. The health route
|
||||||
|
* is the readiness probe (E00-S03-T06): it answers 200 only after the startup
|
||||||
|
* migration run completes, and 503 while the run is still pending.
|
||||||
*/
|
*/
|
||||||
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||||
if (req.method === 'GET' && (req.url ?? '/') === '/health') {
|
if (req.method === 'GET' && (req.url ?? '/') === '/health') {
|
||||||
sendJson(res, 200, HEALTH_PAYLOAD);
|
if (migrationsComplete) {
|
||||||
|
sendJson(res, 200, HEALTH_PAYLOAD);
|
||||||
|
} else {
|
||||||
|
sendJson(res, 503, NOT_READY_PAYLOAD);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendJson(res, 404, NOT_FOUND_PAYLOAD);
|
sendJson(res, 404, NOT_FOUND_PAYLOAD);
|
||||||
@@ -56,6 +96,35 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
|
|
||||||
const server = createServer(handleRequest);
|
const server = createServer(handleRequest);
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (databaseUrl === undefined) {
|
||||||
|
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||||
|
// migrations to run, so the app reports ready from the start.
|
||||||
|
migrationsComplete = true;
|
||||||
|
console.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run');
|
||||||
|
} else {
|
||||||
|
// E00-S03-T06: run the startup migrations; readiness follows completion.
|
||||||
|
const pool = new Pool({ connectionString: databaseUrl });
|
||||||
|
const runner = new MigrationRunner(pool, MIGRATIONS, new MigrationLedger(pool));
|
||||||
|
runner
|
||||||
|
.run()
|
||||||
|
.then((result) => {
|
||||||
|
migrationsComplete = true;
|
||||||
|
console.log(
|
||||||
|
`[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Failure diagnostics are E00-S03-T05 (out of scope for T06): the
|
||||||
|
// runner already throws a serializable MigrationFailedError. The app
|
||||||
|
// logs the failure and stays not-ready, so a deployment with failed
|
||||||
|
// migrations is surfaced by the readiness probe instead of
|
||||||
|
// crash-looping.
|
||||||
|
console.error('[migrate] startup migration run failed; app stays not-ready:', String(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
|
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
|
||||||
* database exclusively through the package-owned `pg` Pool and the
|
* database exclusively through the package-owned `pg` Pool and the
|
||||||
* `MigrationLedger`, so no other package needs the driver to run migrations.
|
* `MigrationLedger`, so no other package needs the driver to run migrations.
|
||||||
|
* The app (apps/server) runs this runner at startup and gates its readiness
|
||||||
|
* on the run (E00-S03-T06): it does not report ready before the run
|
||||||
|
* completes.
|
||||||
*
|
*
|
||||||
* Rollback note from the issue: revert the diagnostic/error handling changes.
|
* Rollback note from the issue: revert the diagnostic/error handling changes.
|
||||||
*/
|
*/
|
||||||
@@ -128,7 +131,8 @@ function structuredCause(cause: unknown): Record<string, unknown> {
|
|||||||
* The migration runner: applies pending migrations in order, exactly once,
|
* The migration runner: applies pending migrations in order, exactly once,
|
||||||
* through the migration ledger. Instances are cheap and share the caller's
|
* through the migration ledger. Instances are cheap and share the caller's
|
||||||
* pool and ledger; the runner performs no locking (advisory lock is
|
* pool and ledger; the runner performs no locking (advisory lock is
|
||||||
* E00-S03-T04) and no ready gating (E00-S03-T06).
|
* E00-S03-T04) and no ready gating (the app gates its readiness on the run,
|
||||||
|
* E00-S03-T06).
|
||||||
*/
|
*/
|
||||||
export class MigrationRunner {
|
export class MigrationRunner {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
|||||||
Generated
+4
@@ -13,6 +13,10 @@ importers:
|
|||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
|
dependencies:
|
||||||
|
'@personal-blog/database-postgres':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/database-postgres
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.13.3
|
specifier: 24.13.3
|
||||||
|
|||||||
Reference in New Issue
Block a user