backup.ts

8.0 KB · raw

1import { Database } from "bun:sqlite";2import {3	cpSync,4	existsSync,5	mkdirSync,6	readdirSync,7	readFileSync,8	renameSync,9	rmSync,10	statSync,11} from "node:fs";12import { join, relative } from "node:path";13import { buildTarball } from "../tarball/tar.ts";14import type { BackupBucket, Config } from "./config.ts";15import { notify, setting } from "./db.ts";1617const KEEP = 14;18const DAY_MS = 24 * 3600 * 1000;19const CHECK_MS = 3600 * 1000;20const NAME = /^\d{8}T\d{6}Z$/;2122/** A backup's name: its UTC time, `20260926T031500Z`, so names sort in time order. */23function stamp(ms: number): string {24	return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");25}2627/** The backups in `data/backups/`, oldest first. */28export function backups(dataDir: string): string[] {29	const root = join(dataDir, "backups");30	// Not a folder is no backups, so the console still shows why the next one fails.31	return existsSync(root) && statSync(root).isDirectory()32		? readdirSync(root)33				.filter((name) => NAME.test(name))34				.sort()35		: [];36}3738/**39 * §19's backup: `VACUUM INTO` of kepr.db and a copy of `assets/`, built in a hidden folder and40 * renamed into `data/backups/<time>/`, so a half-made backup is never the newest. Keeps the last 14.41 */42export function makeBackup(kepr: Database, dataDir: string, now = Date.now()): string {43	const root = join(dataDir, "backups");44	const name = stamp(now);45	const tmp = join(root, `.${name}`);46	rmSync(tmp, { recursive: true, force: true });47	// It holds reporters' contact emails, so only Kepr's user may read it.48	mkdirSync(tmp, { recursive: true, mode: 0o700 });49	// Synchronous from here to the rename: no request can change kepr.db or assets/ in between.50	kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));51	// A restored backup must not bring live sign-ins with it.52	const copy = new Database(join(tmp, "kepr.db"));53	copy.run("DELETE FROM sessions");54	copy.run("VACUUM");55	copy.close();56	const assets = join(dataDir, "assets");57	if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });58	rmSync(join(root, name), { recursive: true, force: true });59	renameSync(tmp, join(root, name));60	for (const old of backups(dataDir).slice(0, -KEEP)) {61		rmSync(join(root, old), { recursive: true, force: true });62	}63	return name;64}6566/** Makes a backup when the newest is a day old or there is none; returns its name, else null. */67export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {68	const newest = backups(dataDir).at(-1);69	return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);70}7172/**73 * Daily backups: checked at startup and every hour, so a restart never skips a day, and an upload74 * to the backup bucket that failed is retried. `config` is the live one the console edits.75 */76export function startBackupLoop(kepr: Database, config: Config): void {77	const tick = () => {78		attemptBackup(kepr, () => backupIfDue(kepr, config.dataDir));79		attemptUpload(kepr, config.dataDir, config.backup).catch((error) =>80			console.error(`kepr: backup upload failed: ${error}`),81		);82	};83	tick();84	setInterval(tick, CHECK_MS);85}8687/**88 * One backup attempt. A failure is kept for the console until a backup succeeds (§24), and only a89 * backup that starts failing is told, not every hourly retry. Returns the failure, or null.90 */91export function attemptBackup(kepr: Database, make: () => unknown): string | null {92	try {93		make();94		kepr.query("DELETE FROM settings WHERE key = 'backup_error'").run();95		return null;96	} catch (error) {97		return keepFailure(kepr, "backup_error", "Backup failed", error);98	}99}100101/** Keeps a failure under `key` for the console, telling the operator only when it starts. */102function keepFailure(kepr: Database, key: string, what: string, error: unknown): string {103	const message = String(error);104	console.error(`kepr: ${what.toLowerCase()}: ${message}`);105	if (!setting(kepr, key)) {106		notify(kepr, "backup_failed", `${what}: ${message}`, "/admin?section=backups");107	}108	kepr.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, message);109	return message;110}111112const OFFSITE = /^kepr-backup-(\d{8}T\d{6}Z)\.tar\.gz$/;113const offsiteKey = (name: string) => `kepr-backup-${name}.tar.gz`;114115function backupClient(bucket: BackupBucket): Bun.S3Client {116	return new Bun.S3Client({117		endpoint: bucket.endpoint,118		bucket: bucket.name,119		region: bucket.region,120		accessKeyId: bucket.accessKeyId,121		secretAccessKey: bucket.secretAccessKey,122	});123}124125/** The backups in the backup bucket, oldest first, with their sizes. */126export async function offsiteBackups(127	bucket: BackupBucket,128): Promise<{ name: string; size: number }[]> {129	const found: { name: string; size: number }[] = [];130	const client = backupClient(bucket);131	let continuationToken: string | undefined;132	do {133		const page = await client.list({ prefix: "kepr-backup-", continuationToken });134		for (const object of page.contents ?? []) {135			const name = OFFSITE.exec(object.key)?.[1];136			if (name) found.push({ name, size: object.size ?? 0 });137		}138		continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;139	} while (continuationToken);140	return found.sort((a, b) => a.name.localeCompare(b.name));141}142143/** Tried before a backup bucket is saved: a test file must write, list and delete. Returns what failed. */144export async function checkBackupBucket(bucket: BackupBucket): Promise<string | null> {145	const client = backupClient(bucket);146	try {147		await client.write("kepr-backup-test", "test", { type: "text/plain" });148		await offsiteBackups(bucket);149		await client.delete("kepr-backup-test");150		return null;151	} catch (error) {152		return `The backup bucket did not take a test file: ${error instanceof Error ? error.message : String(error)}`;153	}154}155156/**157 * Copies the newest local backup to the backup bucket unless it is there already, then keeps the158 * newest 14 there. A failure is kept and told once, as a local one is; null when all is well.159 */160export async function attemptUpload(161	kepr: Database,162	dataDir: string,163	bucket: BackupBucket | undefined,164): Promise<string | null> {165	const newest = backups(dataDir).at(-1);166	if (!bucket || !newest || setting(kepr, "backup_uploaded") === newest) return null;167	try {168		const tarball = backupTarball(dataDir, newest);169		if (!tarball) return null;170		const client = backupClient(bucket);171		await client.write(offsiteKey(newest), tarball.bytes, { type: "application/gzip" });172		for (const old of (await offsiteBackups(bucket)).slice(0, -KEEP)) {173			await client.delete(offsiteKey(old.name));174		}175		kepr176			.query("INSERT OR REPLACE INTO settings (key, value) VALUES ('backup_uploaded', ?)")177			.run(newest);178		kepr.query("DELETE FROM settings WHERE key = 'backup_upload_error'").run();179		return null;180	} catch (error) {181		return keepFailure(kepr, "backup_upload_error", "Backup upload failed", error);182	}183}184185function backupFiles(dataDir: string, name: string): { path: string; full: string }[] {186	const dir = join(dataDir, "backups", name);187	return readdirSync(dir, { recursive: true, withFileTypes: true })188		.filter((entry) => entry.isFile())189		.map((entry) => {190			const full = join(entry.parentPath, entry.name);191			return { path: relative(dir, full), full };192		});193}194195/** What one backup takes on disk. */196export function backupBytes(dataDir: string, name: string): number {197	return backupFiles(dataDir, name).reduce((sum, file) => sum + statSync(file.full).size, 0);198}199200/**201 * A kept backup as a `.tar.gz` for the console's downloads (§24), the newest when no name is202 * given; null when there is no such backup.203 */204export function backupTarball(205	dataDir: string,206	wanted?: string,207): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {208	const kept = backups(dataDir);209	// Only a name from the list, so a request can never reach outside data/backups/.210	const name = wanted === undefined ? kept.at(-1) : kept.find((known) => known === wanted);211	if (!name) return null;212	const files = backupFiles(dataDir, name).map((file) => ({213		path: file.path,214		bytes: readFileSync(file.full),215	}));216	// Owner-only once unpacked: kepr.db holds reporters' contact emails.217	return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };218}