Files
HealthLog/scripts/db-dump.mjs
T
Marc-André Bombeck e665f17f52 feat(backup): dump before migrating, and restore a dump into an empty database
The image now carries postgresql18-client (pg_dump and pg_restore 18.6)
and a /var/lib/healthlog/backups directory owned by the app user, which
the bundled compose file backs with a new `backups` volume for `app` and
the commented `app-worker`; a fresh named volume inherits that owner.

Before `migrate deploy`, when migrations are pending on a database that
has applied some, the entrypoint writes healthlog-<time>-pre-migration.dump
with its manifest and keeps the last two. A failed dump on a writable
directory stops the boot and names DB_BACKUP_BEFORE_MIGRATE=false; without
a writable directory it warns and continues.

`docker compose run --rm db-restore <file|latest|list>` runs
scripts/db-restore.mjs in the app image with plain node. It opens an
off-host .dump.enc with BACKUP_ENCRYPTION_KEY (or a previous key), checks
the archive against the manifest's SHA-256, compares the keys in the
environment with the manifest's fingerprints and refuses on a different
active encryption key unless --ignore-key-mismatch, refuses a database
that holds any table, restores in one transaction, runs ANALYZE, and
compares every table's row count with the manifest, emptying the database
again on a difference.

DB_BACKUP_ENABLED, DB_BACKUP_DIR, DB_BACKUP_SCHEDULE, DB_BACKUP_KEEP and
DB_BACKUP_BEFORE_MIGRATE join the compose whitelist, both example files
and the env manifest.
2026-10-25 12:56:33 +01:00

157 lines
5.0 KiB
JavaScript

/**
* The pre-migration dump: a whole-database dump the entrypoint takes before
* `prisma migrate deploy` applies anything.
*
* node scripts/db-dump.mjs pre-migration
*
* A migration is not undone by going back to the previous image, and the
* bundled compose file pulls `:latest` and migrates on every start. So when
* this image brings migrations the database has not applied, it first writes
* `healthlog-<timestamp>-pre-migration.dump` with its manifest into
* `DB_BACKUP_DIR` (`src/lib/database-backup/dump-core.mjs`), and keeps the
* newest two.
*
* Exit 0: the dump was written, or there was nothing to do (no pending
* migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`), or there is no
* writable directory to put it in, which is said as a warning and is not a
* reason to keep the app down. Exit 1: the directory is writable and the dump
* failed. The entrypoint then stops the boot and names the switch that skips
* the dump, because migrating without the way back the operator was promised
* is the one thing this step exists to prevent.
*
* Fast on purpose: one snapshot, `pg_dump`, a count per table for the
* manifest, `pg_restore --list` to prove the file reads. Nothing else.
*/
import { readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { pathToFileURL } from "node:url";
import {
applyRetention,
connect,
createDatabaseDump,
isWritableDirectory,
latestMigration,
listUserTables,
connectionFromUrl,
preMigrationDumpEnabled,
resolveBackupDir,
resolveDailyKeep,
} from "../src/lib/database-backup/dump-core.mjs";
import { pendingMigrations } from "./pending-migrations.mjs";
const log = (line) => console.log(`HealthLog: ${line}`);
const warn = (line) => console.error(`HealthLog: WARNING — ${line}`);
/** The image's version: the build argument, else package.json beside it. */
function imageVersion(env) {
const fromEnv = (env.NEXT_PUBLIC_APP_VERSION ?? "").trim();
if (fromEnv) return fromEnv;
try {
return JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"))
.version;
} catch {
return null;
}
}
/** Megabytes as an operator reads them. */
function mb(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export async function preMigrationDump(env = process.env) {
if (!preMigrationDumpEnabled(env)) {
log("DB_BACKUP_BEFORE_MIGRATE=false, no dump before migrating.");
return 0;
}
const databaseUrl = env.DATABASE_URL;
if (!databaseUrl) return 0;
let pending;
try {
pending = await pendingMigrations({
connectionString: databaseUrl,
migrationsDir: join(process.cwd(), "prisma", "migrations"),
});
} catch (error) {
warn(
`could not count pending migrations (${error instanceof Error ? error.message : String(error)}); no dump before migrating.`,
);
return 0;
}
if (pending.length === 0) return 0;
// A database no migration has touched holds nothing to lose.
const client = await connect(databaseUrl);
let applied;
let tables;
try {
applied = await latestMigration(
client,
connectionFromUrl(databaseUrl).schema,
);
tables = (await listUserTables(client)).length;
} finally {
await client.end().catch(() => {});
}
if (applied === null && tables === 0) {
log("new database, no dump before the first migrations.");
return 0;
}
const dir = resolveBackupDir(env);
if (!isWritableDirectory(dir)) {
warn(
`${dir} is not writable, so no dump is taken before the ${pending.length} pending migration(s). ` +
"Mount the backups volume (docker-compose.yml) to keep a way back; continuing.",
);
return 0;
}
log(
`taking a database dump before ${pending.length} pending migration(s) into ${dir}...`,
);
const started = Date.now();
try {
const { dumpPath, manifest } = await createDatabaseDump({
databaseUrl,
dir,
kind: "pre-migration",
env,
appVersion: imageVersion(env),
buildSha: (env.NEXT_PUBLIC_APP_BUILD_SHA ?? "").trim() || null,
});
log(
`pre-migration dump written: ${basename(dumpPath)} (${mb(manifest.dump.bytes)}, ` +
`${Object.keys(manifest.rowCounts).length} tables, ${Math.round((Date.now() - started) / 1000)} s).`,
);
} catch (error) {
console.error(
`HealthLog: ERROR — the database dump before migrating failed: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
try {
await applyRetention(dir, { dailyKeep: resolveDailyKeep(env) });
} catch (error) {
warn(
`could not remove old dumps in ${dir} (${error instanceof Error ? error.message : String(error)}).`,
);
}
return 0;
}
async function main() {
const kind = process.argv[2];
if (kind !== "pre-migration") {
console.error("db-dump: usage: node scripts/db-dump.mjs pre-migration");
process.exit(2);
}
process.exit(await preMigrationDump());
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}