mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The image creates DB_BACKUP_DIR owned by the app user, so an install that runs it without the backups volume could write there, and every nightly and pre-migration dump landed in the container's own layer. The next update threw them away while the admin page, the boot line and the readiness card called them kept on this server. A dump directory now counts as kept only when it survives the container: inside a container (Docker, Podman, Kubernetes, a container cgroup or an overlay root) the directory must sit on a mount of its own that is not a tmpfs, read from /proc/self/mountinfo with a device-number fallback; outside one, a writable directory is a disk. Otherwise no dump is written there: the nightly run uploads off-host only and records not_persistent, the pre-migration dump is skipped with a warning, the boot line says the directory is inside the container, the backups page stops promising local dumps and ignores local-only ledger rows, and readiness fails with no bucket and notes the bucket-only setup otherwise. The Unraid template gains a path for the dumps, the Unraid and TrueNAS pages say why it is needed, and the restore drill first runs the stack without the volume and checks that nothing is kept or claimed.
169 lines
5.6 KiB
JavaScript
169 lines
5.6 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
|
|
* directory to put it in that survives the container (none writable, or only
|
|
* the image's own directory without the volume, which the update that brings
|
|
* these migrations is about to throw away), which is said as a warning and is
|
|
* not a reason to keep the app down. Exit 1: the directory is kept 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,
|
|
backupDirectoryStatus,
|
|
connect,
|
|
createDatabaseDump,
|
|
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`;
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, string | undefined>} [env]
|
|
* @param {{ directoryStatus?: typeof backupDirectoryStatus }} [options]
|
|
*/
|
|
export async function preMigrationDump(env = process.env, options = {}) {
|
|
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);
|
|
const status = (options.directoryStatus ?? backupDirectoryStatus)(dir);
|
|
if (!status.persistent) {
|
|
const why = !status.writable
|
|
? "is not writable"
|
|
: status.reason === "memory"
|
|
? "is in memory (tmpfs) and would not keep a dump past a restart"
|
|
: "is inside the container, not on a mounted volume, and would not keep a dump past this update";
|
|
warn(
|
|
`${dir} ${why}, 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();
|
|
}
|