diff --git a/Dockerfile b/Dockerfile index 475e51861..a06be1896 100644 --- a/Dockerfile +++ b/Dockerfile @@ -202,14 +202,20 @@ COPY --from=builder /app/src/generated ./src/generated # `@node-rs/argon2` from the traced runtime tree. Scripts that import the # application graph (`@/…`, the generated Prisma client) run from a source # checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds -# the runbooks and this list in step. Root-owned on purpose: the app user can -# run them but not rewrite them. +# the runbooks and the entrypoint against this list. `pending-migrations.mjs` +# is the entrypoint's own: it logs the pending migration count before +# `migrate deploy`. Root-owned on purpose: the app user can run them but not +# rewrite them. COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs +COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts # Prove the reset CLI's imports resolve in this tree: with every import found # it stops at its own usage line; a missing module throws before reaching it. RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:" +# The boot notice parses, and the `pg` it loads on a real boot resolves here. +RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \ + node --input-type=module -e "await import('pg')" # Install the migration CLI, its config dependency, and a pinned launcher for # the maintenance scripts already shipped in the standalone tree. The Prisma diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3386a8879..8eff71a6b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -85,6 +85,9 @@ done echo "HealthLog: Database is reachable." # ── Run Prisma migrations ──────────────────────────────────── +# Say how many migrations this image is about to apply, with a reminder to +# back up first. Reporting only: it never stops the boot. +node scripts/pending-migrations.mjs || true echo "HealthLog: Running database migrations..." NODE_PATH=/opt/prisma-cli/node_modules \ node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 94f0c047d..1bf418cdc 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -13,6 +13,12 @@ Migrations land **before** the new app container takes traffic. The canonical command sequence on a single-instance host: ```sh +# 0. Dump the database first. A migration is not undone by going back to +# the previous image; the dump is the way back. See "Updates" in +# docs/self-hosting/getting-started.md. +docker compose exec -T db pg_dump -U healthlog healthlog \ + | gzip > healthlog-before-update-$(date +%F).sql.gz + # 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF, # set it to the new one first. docker compose pull app diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 9967227e6..983a8267f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -361,3 +361,42 @@ For off-host encrypted backups to S3/R2/B2 see [`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the encryption key safely (keep the old key until zero rows remain on it) see [`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md). + +## Updates + +A new image can bring database migrations, and `docker-entrypoint.sh` +applies them on the first start of that image. Most are additive, but some +rewrite data, and going back to the previous image does not undo them. The +only way back from a migration that went wrong is the dump you took before +it. So take one before every pull: + +```bash +# 1. Dump the database (same command as under "Backup and restore"). +docker compose exec -T db pg_dump -U healthlog healthlog \ + | gzip > healthlog-before-update-$(date +%F).sql.gz + +# 2. Pull the new image and recreate the app container. +docker compose pull app +docker compose up -d app + +# 3. Confirm the running build. +curl -s http://localhost:3000/api/version +``` + +Keep the dump until the new version has run for a few days, and keep your +encryption key with it: a dump without the key cannot be read. + +The start log says what is about to happen before the migrations run: + +```text +HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image. +HealthLog: Running database migrations... +``` + +or `HealthLog: Database schema is up to date.` when the image brings none. +The line is a notice and does not pause the start. If you see a count and +have no dump yet, stop the container (`docker compose stop app`), take the +dump, and start it again; the migrations run on that start. + +For the ordering on a host where you want the migration result before the +new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md). diff --git a/scripts/pending-migrations.mjs b/scripts/pending-migrations.mjs new file mode 100644 index 000000000..387e062dc --- /dev/null +++ b/scripts/pending-migrations.mjs @@ -0,0 +1,95 @@ +/** + * Boot-time notice before `prisma migrate deploy`: how many migrations this + * image is about to apply, and a reminder to have a backup first. + * + * Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`. + * It only reports. A migration that changes data cannot be undone by pulling + * the previous image, so an operator who reads the count in the log still has + * a moment to stop the container and take a `pg_dump`; the entrypoint does + * not wait for that. + * + * Best effort by design: any failure to count (no `_prisma_migrations` table + * yet is not a failure, it means every migration is pending) prints one line + * saying so and exits 0, because a notice is never a reason to keep the app + * from starting. `migrate deploy` right after it is what decides. + */ +import { readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Migration directory names that carry a `migration.sql`, sorted. */ +export function shippedMigrations(migrationsDir) { + return readdirSync(migrationsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => existsSync(join(migrationsDir, name, "migration.sql"))) + .sort(); +} + +/** + * The shipped migrations the database has not finished applying. Reads + * `_prisma_migrations` in the schema the connection string names (Prisma's + * `?schema=` parameter, `public` when absent); a database without that table + * has applied nothing. + */ +export async function pendingMigrations({ connectionString, migrationsDir }) { + const shipped = shippedMigrations(migrationsDir); + const { default: pg } = await import("pg"); + const url = new URL(connectionString); + const schema = url.searchParams.get("schema") || "public"; + url.searchParams.delete("schema"); + const client = new pg.Client({ + connectionString: url.toString(), + connectionTimeoutMillis: 5_000, + statement_timeout: 5_000, + }); + await client.connect(); + try { + const table = await client.query( + "SELECT to_regclass(format('%I._prisma_migrations', $1::text)) AS t", + [schema], + ); + if (!table.rows[0]?.t) return shipped; + const applied = await client.query( + `SELECT migration_name FROM ${table.rows[0].t} + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`, + ); + const done = new Set(applied.rows.map((row) => row.migration_name)); + return shipped.filter((name) => !done.has(name)); + } finally { + await client.end().catch(() => {}); + } +} + +/** The log line for a count, as the entrypoint prints it. */ +export function pendingNotice(count) { + if (count === 0) return "HealthLog: Database schema is up to date."; + const noun = count === 1 ? "migration" : "migrations"; + return ( + `HealthLog: ${count} database ${noun} pending. ` + + "Back up the database before updating (pg_dump, see the Updates section " + + "of docs/self-hosting/getting-started.md); a migration is not undone by " + + "going back to the previous image." + ); +} + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) return; + try { + const pending = await pendingMigrations({ + connectionString, + migrationsDir: join(process.cwd(), "prisma", "migrations"), + }); + console.log(pendingNotice(pending.length)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.log( + `HealthLog: Could not count pending migrations (${reason}); continuing.`, + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/src/__tests__/container-scripts-ship-guard.test.ts b/src/__tests__/container-scripts-ship-guard.test.ts index 83e2e928f..629a62f48 100644 --- a/src/__tests__/container-scripts-ship-guard.test.ts +++ b/src/__tests__/container-scripts-ship-guard.test.ts @@ -69,6 +69,18 @@ function documentedContainerScripts(): string[] { return [...found].sort(); } +/** Scripts `docker-entrypoint.sh` runs with `node scripts/…` on every boot. */ +function entrypointScripts(): string[] { + const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8"); + return [ + ...new Set( + [...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map( + (m) => m[1], + ), + ), + ].sort(); +} + /** Relative import specifiers of a script, resolved against its location. */ function localImports(script: string): string[] { const src = readFileSync(join(ROOT, script), "utf8"); @@ -109,3 +121,21 @@ describe("scripts the runbooks run inside the container ship in the image", () = expect(missing).toEqual([]); }); }); + +describe("scripts the entrypoint runs ship in the image", () => { + it("finds the boot scripts it is meant to check", () => { + // An empty match would agree with any Dockerfile. + expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs"); + }); + + it("copies every boot script and its local imports into the runner stage", () => { + const missing = entrypointScripts() + .flatMap((script) => [script, ...localImports(script)]) + .filter((path) => !copiedIntoRunner(path)); + expect( + missing, + "docker-entrypoint.sh runs these on every boot, but the Dockerfile " + + "runner stage does not COPY them; the container would fail to start.", + ).toEqual([]); + }); +}); diff --git a/tests/integration/pending-migrations-notice.test.ts b/tests/integration/pending-migrations-notice.test.ts new file mode 100644 index 000000000..3631e6612 --- /dev/null +++ b/tests/integration/pending-migrations-notice.test.ts @@ -0,0 +1,78 @@ +/** + * The boot notice `docker-entrypoint.sh` prints before `migrate deploy` + * (`scripts/pending-migrations.mjs`), against the migrated test database. + * + * Every shipped migration is applied here, so the count is zero; a + * migrations directory with one more entry than the database has applied + * stands for a freshly pulled image, and a schema with no migrations table + * for a first boot. + */ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + pendingMigrations, + pendingNotice, + shippedMigrations, +} from "../../scripts/pending-migrations.mjs"; + +const MIGRATIONS = join(process.cwd(), "prisma", "migrations"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true }); +}); + +function connectionString(): string { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set for the integration run"); + return url; +} + +describe("pending-migrations boot notice", () => { + it("counts nothing pending on a database that has applied every migration", async () => { + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: MIGRATIONS, + }); + expect(shippedMigrations(MIGRATIONS).length).toBeGreaterThan(300); + expect(pending).toEqual([]); + expect(pendingNotice(pending.length)).toBe( + "HealthLog: Database schema is up to date.", + ); + }); + + it("counts a migration the image ships and the database has not applied", async () => { + const dir = mkdtempSync(join(tmpdir(), "hl-migrations-")); + tempDirs.push(dir); + cpSync(MIGRATIONS, dir, { recursive: true }); + mkdirSync(join(dir, "9999_not_applied_yet")); + writeFileSync(join(dir, "9999_not_applied_yet", "migration.sql"), ""); + // A directory without migration.sql is not a migration. + mkdirSync(join(dir, "9998_no_sql")); + + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: dir, + }); + expect(pending).toEqual(["9999_not_applied_yet"]); + const notice = pendingNotice(pending.length); + expect(notice).toContain("1 database migration pending"); + expect(notice).toContain("pg_dump"); + }); + + it("treats a schema without a migrations table as a first boot", async () => { + const url = new URL(connectionString()); + url.searchParams.set("schema", "schema_never_migrated"); + const pending = await pendingMigrations({ + connectionString: url.toString(), + migrationsDir: MIGRATIONS, + }); + expect(pending).toEqual(shippedMigrations(MIGRATIONS)); + expect(pendingNotice(pending.length)).toMatch( + /^HealthLog: \d+ database migrations pending\./, + ); + }); +});