feat(ops): log pending migrations before migrate deploy and document a dump before every update

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:12:57 +02:00
1 parent 648bb0d985
commit eab4d644f7
7 files changed
+259 -2

No files matched your search

+8 -2
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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
+39
View File
@@ -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).
+95
View File
@@ -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();
}
@@ -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([]);
});
});
@@ -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\./,
);
});
});