Files
HealthLog/scripts/db-restore.mjs
T
Marc-André Bombeck a7691223fd fix(db-backup): keep instances apart in a shared bucket, and no decrypted dump behind
Off-host dumps were named by their time alone. Two instances sharing a bucket
(nothing forbids it, and it was safe for the account objects) overwrote each
other's dump at the same second of the same default schedule, and
latest --from-bucket restored whichever instance's dump was newest. Each
instance now uploads under database/<host of APP_URL>/, the object key (and so
the encryption's associated data) carries it, and the restore takes only this
instance's dumps: it refuses another's and names the instances it found,
unless --instance=<host> or the dump's full key says which one on purpose.

A restore killed outright (out of memory, docker kill) left its .restore- or
.download- directory, a whole database decrypted, in the backups directory,
where nothing removed it. A restore now removes them before it starts and on
SIGINT and SIGTERM, and the nightly retention sweeps any older than twelve
hours.
2026-10-25 17:30:29 +01:00

526 lines
18 KiB
JavaScript

/**
* Restore a whole-database dump into an empty database.
*
* docker compose run --rm db-restore list [directory]
* docker compose run --rm db-restore list --from-bucket
* docker compose run --rm db-restore latest [--from-bucket] [--instance=<host>]
* docker compose run --rm db-restore <file> [--ignore-key-mismatch]
*
* Runs in the app image with plain `node` (no `tsx`, no source checkout), as
* the `db-restore` compose service, so a new server needs only the compose
* file, the keys and the dump. `<file>` is a name in `DB_BACKUP_DIR` or a
* path inside the container: a `.dump` written on a server, or a `.dump.enc`
* downloaded from the off-host bucket (`database/` prefix), with its manifest
* (`.manifest.json` or `.manifest.json.enc`) beside it. With `--from-bucket`
* it fetches the pair from the bucket the `BACKUP_S3_*` variables name (the
* newest for `latest`), which is the restore after the host and its local
* dumps are gone. Only this instance's dumps count there: the ones under
* `database/<host of APP_URL>/`. A bucket shared with another instance holds
* its dumps too, and `latest` refuses rather than restore one of them; name
* the other instance with `--instance=<host>` (after a move to a new
* domain, the old one) or the dump by its full key to restore it on purpose.
*
* A copy opened from the bucket is a whole database, decrypted, in a scratch
* directory beside the backups. It is removed when the restore ends, on
* Ctrl-C and `docker stop` too; one left by a restore that was killed outright
* is removed when the next restore starts, and by the nightly retention.
*
* In this order, and it stops at the first thing that is wrong, before it
* writes anything:
* 1. an off-host copy is opened with BACKUP_ENCRYPTION_KEY (or a key in
* BACKUP_ENCRYPTION_PREVIOUS_KEYS) into a temporary file;
* 2. the dump's SHA-256 and size must equal the manifest's;
* 3. the keys in this environment are compared with the manifest's
* fingerprints; each one missing or different is named, and a mismatch
* of the active encryption key refuses (`--ignore-key-mismatch` restores
* anyway, for an operator who knows the key is lost);
* 4. the target database must hold no table at all. This never overwrites.
* Then `pg_restore` in one transaction, `ANALYZE`, and a count of every table
* against the manifest. A count that differs empties the database again and
* fails, so the app is never started on a restore that does not match.
*
* Exit 0 restored (or listed), 1 refused or failed, 2 usage.
*/
import { rmSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join } from "node:path";
import { pathToFileURL } from "node:url";
import {
compareKeyFingerprints,
compareRowCounts,
connect,
countRows,
decryptOffhostFile,
emptyDatabase,
hashFile,
isGoodSet,
isWritableDirectory,
keyFingerprints,
listDumpSets,
listUserTables,
offhostKeysFromEnv,
offhostObjectKey,
parseDumpFileName,
parseManifest,
resolveBackupDir,
runTool,
statOrNull,
connectionFromUrl,
bucketConfigFromEnv,
openBucket,
pickBucketDump,
OFFHOST_DATABASE_PREFIX,
instanceIdFromEnv,
listBucketDumps,
removeStaleScratch,
} from "../src/lib/database-backup/dump-core.mjs";
import { shippedMigrations } from "./pending-migrations.mjs";
const out = (line = "") => console.log(line);
const say = (line) => console.log(`db-restore: ${line}`);
class Refusal extends Error {}
/**
* The scratch directories this process holds, removed on the way out when a
* signal ends it before its own `finally` can.
*/
const liveScratch = new Set();
async function makeScratch(dir, prefix) {
const path = await mkdtemp(
join(isWritableDirectory(dir) ? dir : tmpdir(), prefix),
);
liveScratch.add(path);
return path;
}
async function dropScratch(path) {
liveScratch.delete(path);
await rm(path, { recursive: true, force: true });
}
/** The instance whose bucket dumps count: `--instance=<host>`, else APP_URL's. */
function bucketInstance(flags, env) {
const named = [...flags].find((f) => f.startsWith("--instance="));
return named
? named.slice("--instance=".length) || null
: instanceIdFromEnv(env);
}
function mb(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* Whether this image knows the dump's newest migration. A dump from a newer
* release restored under an older image leaves the app with a schema its code
* does not expect, so the restore says which image to run.
*/
function imageKnowsMigration(name) {
if (!name) return true;
try {
return shippedMigrations(
join(process.cwd(), "prisma", "migrations"),
).includes(name);
} catch {
return true;
}
}
function listCommand(dir) {
const sets = listDumpSets(dir);
if (sets.length === 0) {
say(`no dumps in ${dir}`);
return 0;
}
out(`Dumps in ${dir}, newest first:`);
for (const set of sets) {
const state = isGoodSet(set)
? `${mb(set.dump.bytes)}`
: set.encryptedDump
? "off-host copy (.enc)"
: set.dump
? "no manifest, not restorable"
: "incomplete";
out(
` ${set.base}.dump${set.encryptedDump && !set.dump ? ".enc" : ""} ${set.kind} ${set.timestamp.toISOString()} ${state}`,
);
}
return 0;
}
/**
* The dump and manifest to restore: a local pair, or an encrypted pair
* opened into `scratch`.
*/
async function resolveInput(arg, dir, env, objectKeys = null) {
if (arg === "latest") {
const set = listDumpSets(dir).find(
(s) =>
isGoodSet(s) ||
(s.encryptedDump !== null &&
(s.encryptedManifest !== null || s.manifest !== null)),
);
if (!set) throw new Refusal(`no restorable dump in ${dir}`);
arg = set.dump ? set.dump.path : set.encryptedDump;
}
const path = isAbsolute(arg) ? arg : join(dir, arg);
if (!(await statOrNull(path))) throw new Refusal(`${path} does not exist`);
const name = basename(path);
const parsed = parseDumpFileName(name);
if (!parsed || parsed.role !== "dump" || parsed.variant === "partial") {
throw new Refusal(
`${name} is not a HealthLog database dump (healthlog-<time>-<kind>.dump or .dump.enc)`,
);
}
const folder = dirname(path);
const plainManifest = join(folder, `${parsed.base}.manifest.json`);
const encManifest = `${plainManifest}.enc`;
if (parsed.variant === "final") {
if (!(await statOrNull(plainManifest))) {
throw new Refusal(
`${basename(plainManifest)} is missing beside ${name}; a dump is restored only with its manifest`,
);
}
return {
dumpPath: path,
manifest: parseManifest(
JSON.parse(await readFile(plainManifest, "utf8")),
),
scratch: null,
};
}
// An off-host copy. Open it next to the backups if there is room to write
// there, in the container's temp directory otherwise.
const keys = offhostKeysFromEnv(env);
if (keys.length === 0) {
throw new Refusal(
"this is an off-host copy and BACKUP_ENCRYPTION_KEY is not set; it cannot be opened without it",
);
}
const scratch = await makeScratch(dir, ".restore-");
// The name the copy had in the bucket is part of what its encryption is
// bound to. Fetched from the bucket here, it is known; a file downloaded by
// hand was under this instance's folder, or at the top of `database/`.
const instance = instanceIdFromEnv(env);
const keyCandidates = (fileName, fetched) =>
fetched
? [fetched]
: [
...(instance ? [offhostObjectKey(fileName, instance)] : []),
offhostObjectKey(fileName),
];
const open = async (inPath, outPath, candidates) => {
let last;
for (const objectKey of candidates) {
try {
await decryptOffhostFile({ inPath, outPath, objectKey, keys });
return;
} catch (error) {
last = error;
}
}
throw last;
};
try {
let manifest;
if (await statOrNull(encManifest)) {
const opened = join(scratch, `${parsed.base}.manifest.json`);
await open(
encManifest,
opened,
keyCandidates(basename(encManifest), objectKeys?.manifest),
);
manifest = parseManifest(JSON.parse(await readFile(opened, "utf8")));
} else if (await statOrNull(plainManifest)) {
manifest = parseManifest(
JSON.parse(await readFile(plainManifest, "utf8")),
);
} else {
throw new Refusal(
`${basename(encManifest)} is missing beside ${name}; download it from the bucket too`,
);
}
say(`opening the off-host copy ${name}...`);
const dumpPath = join(scratch, `${parsed.base}.dump`);
await open(path, dumpPath, keyCandidates(name, objectKeys?.dump));
return { dumpPath, manifest, scratch };
} catch (error) {
await dropScratch(scratch);
throw error instanceof Refusal ? error : new Refusal(error.message);
}
}
/**
* Fetch a dump and its manifest from the off-host bucket (`BACKUP_S3_*`) into
* a scratch directory: the newest pair for `latest`, the named one otherwise.
* Answers the downloaded `.dump.enc` and the directory to remove afterwards.
*/
async function downloadFromBucket(arg, dir, env, instance) {
const config = bucketConfigFromEnv(env);
if (!config) {
throw new Refusal(
"--from-bucket needs BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY and BACKUP_S3_SECRET_KEY",
);
}
const bucket = await openBucket(config);
const keys = await bucket.list(OFFHOST_DATABASE_PREFIX);
const pick = pickBucketDump(
keys,
arg === "latest"
? null
: arg.startsWith(OFFHOST_DATABASE_PREFIX)
? arg
: basename(arg),
instance,
);
if (!pick) {
const folder = `${OFFHOST_DATABASE_PREFIX}${instance ? `${instance}/` : ""}`;
const others = [
...new Set(
listBucketDumps(keys)
.filter((c) => c.instance !== instance)
.map((c) => c.instance ?? "(no instance, top of database/)"),
),
];
throw new Refusal(
(arg === "latest"
? `no database dump of this instance (${instance ?? "APP_URL is not set"}) with its manifest under ${folder} in bucket ${config.bucket}`
: `${basename(arg)} and its manifest are not both under ${folder} in bucket ${config.bucket}`) +
(others.length > 0
? `. The bucket holds dumps of: ${others.join(", ")}. To restore one of those on purpose, run again with --instance=<host> or name the dump by its full key.`
: ""),
);
}
const scratch = await makeScratch(dir, ".download-");
try {
say(
`downloading ${pick.dumpKey} and its manifest from ${config.bucket}...`,
);
const dumpPath = join(scratch, basename(pick.dumpKey));
await bucket.download(
pick.manifestKey,
join(scratch, basename(pick.manifestKey)),
);
await bucket.download(pick.dumpKey, dumpPath);
return {
dumpPath,
scratch,
objectKeys: { dump: pick.dumpKey, manifest: pick.manifestKey },
};
} catch (error) {
await dropScratch(scratch);
throw new Refusal(
`could not download from the bucket: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function listBucketCommand(flags, env) {
const config = bucketConfigFromEnv(env);
if (!config)
throw new Refusal("--from-bucket needs the BACKUP_S3_* variables");
const instance = bucketInstance(flags, env);
const dumps = listBucketDumps(
await (await openBucket(config)).list(OFFHOST_DATABASE_PREFIX),
);
out(
`Dumps in bucket ${config.bucket} under ${OFFHOST_DATABASE_PREFIX}, newest first (this instance: ${instance ?? "APP_URL is not set"}):`,
);
for (const dump of dumps) {
out(
` ${dump.dumpKey}${dump.instance === instance ? "" : " (another instance)"}`,
);
}
if (dumps.length === 0) say("none");
return 0;
}
async function restoreCommand(arg, flags, env) {
const dir = resolveBackupDir(env);
const databaseUrl = env.DATABASE_URL;
if (!databaseUrl) throw new Refusal("DATABASE_URL is not set");
// A decrypted copy a killed restore left behind goes before anything else.
for (const path of await removeStaleScratch(dir, { olderThanMs: 0 })) {
say(
`removed ${basename(path)}, left behind by a restore that did not finish`,
);
}
let input = arg;
let download = null;
if (flags.has("--from-bucket")) {
download = await downloadFromBucket(
arg,
dir,
env,
bucketInstance(flags, env),
);
input = download.dumpPath;
}
let resolved;
try {
resolved = await resolveInput(
input,
dir,
env,
download ? download.objectKeys : null,
);
} finally {
// The sealed copy is not needed once it is opened (or failed to open).
if (download) await dropScratch(download.scratch);
}
const { dumpPath, manifest, scratch } = resolved;
try {
say(
`dump ${manifest.file}: taken ${manifest.createdAt} by HealthLog ${manifest.appVersion ?? "(unknown version)"}, ` +
`newest migration ${manifest.latestMigration ?? "(none)"}, Postgres ${manifest.postgres.serverVersion}, ` +
`${Object.keys(manifest.rowCounts).length} tables`,
);
if (!imageKnowsMigration(manifest.latestMigration)) {
say(
`warning: this image does not know the dump's newest migration ${manifest.latestMigration}; ` +
`start the app with HealthLog ${manifest.appVersion ?? "the version that wrote it"} or newer (HEALTHLOG_IMAGE_REF), not this image`,
);
}
if (basename(dumpPath) !== manifest.file) {
throw new Refusal(
`the manifest describes ${manifest.file}, not ${basename(dumpPath)}`,
);
}
// 2. The file is the one the manifest describes.
const { sha256, bytes } = await hashFile(dumpPath);
if (sha256 !== manifest.dump.sha256 || bytes !== manifest.dump.bytes) {
throw new Refusal(
`the dump does not match its manifest (SHA-256 ${sha256.slice(0, 12)}…, ${bytes} bytes; ` +
`the manifest says ${manifest.dump.sha256.slice(0, 12)}…, ${manifest.dump.bytes} bytes). It is damaged or not this dump.`,
);
}
say(`checksum matches the manifest (${mb(bytes)})`);
// 3. The keys.
const keys = compareKeyFingerprints(manifest.keys, keyFingerprints(env));
if (keys.findings.length === 0) {
say("every key the dump was written with is configured here");
}
for (const finding of keys.findings) {
say(
`${finding.severity === "refuse" ? "KEY MISMATCH" : "warning"}: ${finding.key}: ${finding.detail}`,
);
}
if (keys.refuse && !flags.has("--ignore-key-mismatch")) {
throw new Refusal(
"refusing to restore: set the encryption keys this dump was written with (see above). " +
"Only if that key is lost for good, run again with --ignore-key-mismatch; everything encrypted will stay unreadable.",
);
}
// 4. Never over existing data.
const client = await connect(databaseUrl);
try {
const tables = await listUserTables(client);
if (tables.length > 0) {
throw new Refusal(
`the target database already holds ${tables.length} ${tables.length === 1 ? "table" : "tables"} (${tables.slice(0, 3).join(", ")}${tables.length > 3 ? ", …" : ""}). ` +
"A restore goes only into an empty database: start it on a new, empty database volume, and do not start the app before the restore.",
);
}
say("restoring (one transaction)...");
const started = Date.now();
await runTool(
"pg_restore",
[
"--no-owner",
"--no-privileges",
"--single-transaction",
"--exit-on-error",
"--no-password",
`--dbname=${connectionFromUrl(databaseUrl).pgEnv.PGDATABASE}`,
dumpPath,
],
{ env: connectionFromUrl(databaseUrl).pgEnv },
);
say(`restored in ${Math.round((Date.now() - started) / 1000)} s`);
await client.query("ANALYZE");
say("planner statistics gathered");
const problems = compareRowCounts(
manifest.rowCounts,
await countRows(client),
);
if (problems.length > 0) {
for (const line of problems.slice(0, 20)) say(`row count: ${line}`);
await emptyDatabase(client);
throw new Refusal(
`${problems.length} table(s) do not match the manifest; the database was emptied again. Do not start the app on it.`,
);
}
say(
`all ${Object.keys(manifest.rowCounts).length} tables hold the row counts the manifest names`,
);
} finally {
await client.end().catch(() => {});
}
out();
out(
`Restored ${manifest.file}. Start the app with the image this dump came from or a newer one ` +
`(HealthLog ${manifest.appVersion ?? "?"}, newest migration ${manifest.latestMigration ?? "none"}): docker compose up -d`,
);
return 0;
} finally {
if (scratch) await dropScratch(scratch);
}
}
export async function main(argv = process.argv.slice(2), env = process.env) {
const flags = new Set(argv.filter((a) => a.startsWith("--")));
const [command] = argv.filter((a) => !a.startsWith("--"));
if (!command) {
console.error(
"db-restore: usage: db-restore list [directory] | latest | <file> [--from-bucket [--instance=<host>]] [--ignore-key-mismatch]",
);
return 2;
}
try {
if (command === "list" && flags.has("--from-bucket")) {
return await listBucketCommand(flags, env);
}
if (command === "list") {
const [, other] = argv.filter((a) => !a.startsWith("--"));
return listCommand(other ?? resolveBackupDir(env));
}
return await restoreCommand(command, flags, env);
} catch (error) {
console.error(
`db-restore: ${error instanceof Refusal ? "" : "failed: "}${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
// Ctrl-C or `docker stop` ends the process before its own `finally`: take
// the decrypted copy with it.
for (const [signal, code] of [
["SIGINT", 130],
["SIGTERM", 143],
]) {
process.on(signal, () => {
for (const path of liveScratch) {
rmSync(path, { recursive: true, force: true });
}
process.exit(code);
});
}
process.exit(await main());
}