mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
157 lines
5.0 KiB
JavaScript
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();
|
|
}
|