/** * 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=] * docker compose run --rm db-restore [--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. `` 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//`. 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=` (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=`, 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-