feat(backup): restore a dump straight from the off-host bucket

A lost host takes its local dumps with it, and the copy left is the one in
the bucket. `docker compose run --rm db-restore latest --from-bucket` now
lists `database/` in the bucket the BACKUP_S3_* variables name, takes the
newest dump whose manifest is there too (or the one named), downloads both
with the S3 client the image already ships, and continues as for any
off-host copy: opened with BACKUP_ENCRYPTION_KEY, checked against the
manifest, restored into an empty database. `list --from-bucket` shows what
is there. The sealed download is removed once it is opened.

The image build now proves the S3 client resolves from the shared module's
place in the tree, so the restore cannot lose it unnoticed.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:43:23 +01:00
1 parent 1a4289545e
commit 82db3de30a
6 files changed
+298 -10

No files matched your search

+4 -2
View File
@@ -265,9 +265,11 @@ RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')" && \
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
# The dump and restore scripts load, and the client tools they run are the
# major the bundled database runs.
# The dump and restore scripts load, the client tools they run are the
# major the bundled database runs, and the S3 client `db-restore --from-bucket`
# loads resolves from the shared module's place in the tree.
RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
node --input-type=module -e "import { createRequire } from 'node:module'; createRequire('/app/src/lib/database-backup/dump-core.mjs').resolve('@aws-sdk/client-s3')" && \
node scripts/db-restore.mjs 2>&1 | grep -q "db-restore: usage:" && \
pg_dump --version | grep -q "(PostgreSQL) 18\." && \
pg_restore --version | grep -q "(PostgreSQL) 18\."
+7 -4
View File
@@ -806,15 +806,18 @@ services:
# docker compose run --rm db-restore list
# docker compose run --rm db-restore latest
# docker compose run --rm db-restore healthlog-<time>-daily.dump
# docker compose run --rm db-restore latest --from-bucket
#
# It checks the dump against its manifest and the keys in .env against the
# keys the dump was written with, refuses a database that already holds
# tables, restores in one transaction and compares every table's row count
# with the manifest. Start the app only after it says "Restored".
# An off-host copy (`database/…dump.enc` and its `.manifest.json.enc`)
# restores the same way once both files are in the backups volume, or from a
# directory you mount: `docker compose run --rm -v ./from-bucket:/restore:ro
# db-restore /restore/healthlog-<time>-daily.dump.enc`.
# `--from-bucket` downloads the dump and its manifest from the off-host bucket
# (`database/`, the BACKUP_S3_* variables) and opens them with
# BACKUP_ENCRYPTION_KEY: the restore after the host and its local dumps are
# gone. Files you downloaded yourself restore from a directory you mount:
# `docker compose run --rm -v ./restore:/restore:ro db-restore
# /restore/healthlog-<time>-daily.dump.enc`.
db-restore:
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
profiles: ["restore"]
+6
View File
@@ -147,6 +147,12 @@ describe("db-restore refuses before it writes", () => {
expect(await main([], env())).toBe(2);
});
it("refuses --from-bucket without a configured bucket, before it connects", async () => {
expect(await main(["latest", "--from-bucket"], env())).toBe(1);
expect(errors.join("\n")).toMatch(/--from-bucket needs BACKUP_S3_ENDPOINT/);
expect(runTool).not.toHaveBeenCalled();
});
it("lists what is there", async () => {
writeDump();
expect(await main(["list"], env())).toBe(0);
+88 -4
View File
@@ -2,7 +2,8 @@
* Restore a whole-database dump into an empty database.
*
* docker compose run --rm db-restore list [directory]
* docker compose run --rm db-restore latest
* docker compose run --rm db-restore list --from-bucket
* docker compose run --rm db-restore latest [--from-bucket]
* docker compose run --rm db-restore <file> [--ignore-key-mismatch]
*
* Runs in the app image with plain `node` (no `tsx`, no source checkout), as
@@ -10,7 +11,10 @@
* 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.
* (`.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.
*
* In this order, and it stops at the first thing that is wrong, before it
* writes anything:
@@ -54,6 +58,10 @@ import {
runTool,
statOrNull,
connectionFromUrl,
bucketConfigFromEnv,
openBucket,
pickBucketDump,
OFFHOST_DATABASE_PREFIX,
} from "../src/lib/database-backup/dump-core.mjs";
import { shippedMigrations } from "./pending-migrations.mjs";
@@ -193,12 +201,85 @@ async function resolveInput(arg, dir, env) {
}
}
/**
* 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) {
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 : basename(arg));
if (!pick) {
throw new Refusal(
arg === "latest"
? `no database dump with its manifest under ${OFFHOST_DATABASE_PREFIX} in bucket ${config.bucket}`
: `${basename(arg)} and its manifest are not both under ${OFFHOST_DATABASE_PREFIX} in bucket ${config.bucket}`,
);
}
const scratch = await mkdtemp(
join(isWritableDirectory(dir) ? dir : tmpdir(), ".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 };
} catch (error) {
await rm(scratch, { recursive: true, force: true });
throw new Refusal(
`could not download from the bucket: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function listBucketCommand(env) {
const config = bucketConfigFromEnv(env);
if (!config)
throw new Refusal("--from-bucket needs the BACKUP_S3_* variables");
const keys = (await (await openBucket(config)).list(OFFHOST_DATABASE_PREFIX))
.filter((k) => k.endsWith(".dump.enc"))
.sort()
.reverse();
out(
`Dumps in bucket ${config.bucket} under ${OFFHOST_DATABASE_PREFIX}, newest first:`,
);
for (const key of keys) out(` ${key}`);
if (keys.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");
const { dumpPath, manifest, scratch } = await resolveInput(arg, dir, env);
let input = arg;
let download = null;
if (flags.has("--from-bucket")) {
download = await downloadFromBucket(arg, dir, env);
input = download.dumpPath;
}
let resolved;
try {
resolved = await resolveInput(input, dir, env);
} finally {
// The sealed copy is not needed once it is opened (or failed to open).
if (download) await rm(download.scratch, { recursive: true, force: true });
}
const { dumpPath, manifest, scratch } = resolved;
try {
say(
`dump ${manifest.file}: taken ${manifest.createdAt} by HealthLog ${manifest.appVersion ?? "(unknown version)"}, ` +
@@ -308,11 +389,14 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
const [command] = argv.filter((a) => !a.startsWith("--"));
if (!command) {
console.error(
"db-restore: usage: db-restore list [directory] | latest | <file> [--ignore-key-mismatch]",
"db-restore: usage: db-restore list [directory] | latest | <file> [--from-bucket] [--ignore-key-mismatch]",
);
return 2;
}
try {
if (command === "list" && flags.has("--from-bucket")) {
return await listBucketCommand(env);
}
if (command === "list") {
const [, other] = argv.filter((a) => !a.startsWith("--"));
return listCommand(other ?? resolveBackupDir(env));
@@ -25,6 +25,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyRetention,
bucketConfigFromEnv,
pickBucketDump,
buildManifest,
compareKeyFingerprints,
compareRowCounts,
@@ -628,3 +630,60 @@ describe("an off-host copy read back by the restore", () => {
expect(await readdir(dir)).toEqual([name]);
});
});
describe("finding a dump in the bucket", () => {
const keys = [
"database/healthlog-20261023T013000Z-daily.dump.enc",
"database/healthlog-20261023T013000Z-daily.manifest.json.enc",
"database/healthlog-20261024T013000Z-daily.dump.enc",
"database/healthlog-20261024T013000Z-daily.manifest.json.enc",
// The newest dump whose manifest never arrived, and things that are
// not dumps at all.
"database/healthlog-20261025T013000Z-daily.dump.enc",
"2026-10-25/user-abc.json.enc",
"database/notes.txt",
];
it("takes the newest dump whose manifest is there too", () => {
expect(pickBucketDump(keys)).toEqual({
dumpKey: "database/healthlog-20261024T013000Z-daily.dump.enc",
manifestKey:
"database/healthlog-20261024T013000Z-daily.manifest.json.enc",
});
});
it("finds a named one with or without its suffix, and nothing else", () => {
for (const name of [
"healthlog-20261023T013000Z-daily.dump",
"healthlog-20261023T013000Z-daily.dump.enc",
"database/healthlog-20261023T013000Z-daily.dump.enc",
]) {
expect(pickBucketDump(keys, name)?.dumpKey).toBe(
"database/healthlog-20261023T013000Z-daily.dump.enc",
);
}
expect(
pickBucketDump(keys, "healthlog-20261025T013000Z-daily.dump"),
).toBeNull();
expect(pickBucketDump([])).toBeNull();
});
it("reads the bucket from the four required variables", () => {
const env = {
BACKUP_S3_ENDPOINT: "https://s3.example.test",
BACKUP_S3_BUCKET: "b",
BACKUP_S3_ACCESS_KEY: "a",
BACKUP_S3_SECRET_KEY: "s",
};
expect(bucketConfigFromEnv(env)).toEqual({
endpoint: "https://s3.example.test",
bucket: "b",
accessKey: "a",
secretKey: "s",
region: "auto",
});
expect(
bucketConfigFromEnv({ ...env, BACKUP_S3_SECRET_KEY: "" }),
).toBeNull();
});
});
+134
View File
@@ -1127,6 +1127,140 @@ export function offhostKeysFromEnv(env = process.env) {
.map((k) => /** @type {Buffer} */ (k));
}
/**
* @typedef {{
* endpoint: string;
* bucket: string;
* accessKey: string;
* secretKey: string;
* region: string;
* }} BucketConfig
*/
/**
* The off-host bucket an environment names (`BACKUP_S3_*`), or null when one
* of the four required variables is missing.
*
* @param {Env} env
* @returns {BucketConfig | null}
*/
export function bucketConfigFromEnv(env = process.env) {
const value = (/** @type {string} */ name) => (env[name] ?? "").trim();
const config = {
endpoint: value("BACKUP_S3_ENDPOINT"),
bucket: value("BACKUP_S3_BUCKET"),
accessKey: value("BACKUP_S3_ACCESS_KEY"),
secretKey: value("BACKUP_S3_SECRET_KEY"),
region: value("BACKUP_S3_REGION") || "auto",
};
return config.endpoint &&
config.bucket &&
config.accessKey &&
config.secretKey
? config
: null;
}
/**
* The newest database dump in a bucket listing whose manifest is there too,
* or the one `name` asks for. Pure. `name` may carry the `.enc` suffix or
* not. Answers both object keys, or null.
*
* @param {string[]} keys every key under the `database/` prefix
* @param {string | null} name
* @returns {{ dumpKey: string; manifestKey: string } | null}
*/
export function pickBucketDump(keys, name = null) {
const present = new Set(keys);
const wanted = name
? name.replace(/\.enc$/, "").replace(/^database\//, "")
: null;
const candidates = keys
.map((key) => ({
key,
parsed: parseDumpFileName(key.slice(OFFHOST_DATABASE_PREFIX.length)),
}))
.filter(
({ key, parsed }) =>
key.startsWith(OFFHOST_DATABASE_PREFIX) &&
parsed !== null &&
parsed.role === "dump" &&
parsed.variant === "encrypted" &&
(wanted === null || `${parsed.base}.dump` === wanted),
)
.map(({ parsed }) => {
const p = /** @type {ParsedDumpName} */ (parsed);
return {
timestamp: p.timestamp.getTime(),
dumpKey: offhostObjectKey(`${p.base}.dump.enc`),
manifestKey: offhostObjectKey(`${p.base}.manifest.json.enc`),
};
})
.filter((c) => present.has(c.manifestKey))
.sort((a, b) => b.timestamp - a.timestamp);
return candidates[0]
? { dumpKey: candidates[0].dumpKey, manifestKey: candidates[0].manifestKey }
: null;
}
/**
* A minimal reader of the off-host bucket for the restore: list a prefix and
* download an object to a file, streamed. Uses the AWS SDK the app ships
* (path-style requests, as the app's own uploader does).
*
* @param {BucketConfig} config
*/
export async function openBucket(config) {
const sdk = await import("@aws-sdk/client-s3");
const client = new sdk.S3Client({
endpoint: config.endpoint,
region: config.region,
forcePathStyle: true,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey,
},
});
return {
/** @param {string} prefix */
async list(prefix) {
/** @type {string[]} */
const keys = [];
/** @type {string | undefined} */
let token;
for (let page = 0; page < 200; page++) {
const out = await client.send(
new sdk.ListObjectsV2Command({
Bucket: config.bucket,
Prefix: prefix,
ContinuationToken: token,
}),
);
for (const c of out.Contents ?? []) if (c.Key) keys.push(c.Key);
if (!out.IsTruncated || !out.NextContinuationToken) return keys;
token = out.NextContinuationToken;
}
throw new Error(`the bucket listing under ${prefix} did not end`);
},
/**
* @param {string} key
* @param {string} outPath
*/
async download(key, outPath) {
const out = await client.send(
new sdk.GetObjectCommand({ Bucket: config.bucket, Key: key }),
);
if (!out.Body) throw new Error(`the bucket returned no body for ${key}`);
await pipeline(
/** @type {NodeJS.ReadableStream} */ (
/** @type {unknown} */ (out.Body)
),
createWriteStream(outPath, { flags: "wx", mode: 0o600 }),
);
},
};
}
// ── Restore ────────────────────────────────────────────────────────────────
/**