mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(ops): log pending migrations before migrate deploy and document a dump before every update
This commit is contained in:
1 parent
648bb0d985
commit
eab4d644f7
7 files changed
+259
-2
No files matched your search
+8
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user