Files
mesh-catalog/modules/restic/client.ts
T
jschoubben 32cd92baeb Back up every store: the restic module holds node-backup, the stores contribute their dumps
ADR 0214 / to-be 43. restic keeps one repository per machine and takes a nightly snapshot per
module — 14 daily, 8 weekly, 6 monthly — and restores beside the live data, never over it. postgres,
mssql and mongodb contribute a consistent dump; minio, influxdb, the vault, mailu, gitea and
nextcloud the directories that hold their data.
2026-10-05 11:47:59 +02:00

313 lines
13 KiB
TypeScript

// restic's own code, in the module (novox/hq ADR 0039): the machine's backups (ADR 0214, to-be 43).
//
// The mesh composes what to back up: every module on the machine contributes `backup` lines to the
// node-backup seat, and the mesh writes them, each module's under a `# <module>` line and with its
// directories already filled, into one file this module reads. Two kinds of line:
//
// run <shell command> run as root before the module's snapshot — a consistent dump of a store
// path <directory> a directory the module's snapshot keeps
//
// Each module gets one snapshot a night, tagged with its name, so a module is listed, kept and
// restored on its own. Everything lands in one repository on the machine — deduplicated, so every
// night is a complete restore point and only what changed costs space — and is thinned to 14 daily,
// 8 weekly and 6 monthly. Against mistakes, not disasters: nothing leaves the machine.
//
// Root's: the dumps read every store and the repository holds every module's data, and the runtime
// loading this bundle runs as the operator's account, so restic and the run lines go through sudo
// without a prompt where the account is not root — fail2ban's way (ADR 0175 §4).
import { execFile } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
/** A command runner, so the backups can be tested without restic or a store. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** The command as it is run: as given when this process is root, else through sudo without a prompt. */
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
export const execRunner: Runner = async (cmd, args) => {
const [program, argv] = escalated(cmd, args);
try {
// A night's dump of a large store takes a while; six hours is a dump that will not finish.
const { stdout } = await execFileP(program, argv, { maxBuffer: 256 * 1024 * 1024, timeout: 6 * 3600 * 1000 });
return stdout;
} catch (err) {
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
const said = `${e.stderr ?? ""}`.trim() || `${e.stdout ?? ""}`.trim();
if (program === "sudo" && /^sudo:/m.test(said)) {
throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
}
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
throw new Error(lines.length ? lines.slice(-3).join(" / ") : (e.message ?? `${cmd} failed`));
}
};
/** What one module declared. */
export interface Declared {
module: string;
runs: string[];
paths: string[];
}
/** The composed file, read into each module's declaration, in the order the mesh wrote them. A line
* before any module, a comment that is not a module's name, or a blank, is nothing. */
export function parseDeclared(text: string): Declared[] {
const out: Declared[] = [];
let current: Declared | undefined;
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line === "") continue;
const header = /^#\s*([a-z0-9][a-z0-9-]*)$/.exec(line);
if (header) {
current = { module: header[1], runs: [], paths: [] };
out.push(current);
continue;
}
if (line.startsWith("#") || !current) continue;
const [kind, ...rest] = line.split(/\s+/);
const value = line.slice(kind.length).trim();
if (kind === "run" && value) current.runs.push(value);
else if (kind === "path" && rest.length === 1 && value.startsWith("/")) current.paths.push(value);
else throw new Error(`${current.module} contributes a backup line this holder does not read: ${line}`);
}
return out.filter((d) => d.runs.length > 0 || d.paths.length > 0);
}
/** One restore point, as restic lists it. */
export interface Snapshot {
id: string;
short_id: string;
time: string;
paths: string[];
tags?: string[];
hostname: string;
}
/** How one module's last night went. */
export interface Night {
ok: boolean;
at: string;
snapshot?: string;
error?: string;
}
export const KEEP = { daily: 14, weekly: 8, monthly: 6 };
export function tagOf(module: string): string {
return `module=${module}`;
}
export interface Where {
declared: string;
repository: string;
passwordFile: string;
state: string;
}
export function whereFromEnv(env: NodeJS.ProcessEnv = process.env): Where {
const need = (k: string) => {
const v = env[k];
if (!v) throw new Error(`${k} is not set; the mesh gives it to this module's tools`);
return v;
};
return {
declared: need("MESH_BACKUP_DECLARED"),
repository: need("MESH_BACKUP_REPOSITORY"),
passwordFile: need("MESH_BACKUP_PASSWORD_FILE"),
state: need("MESH_BACKUP_STATE"),
};
}
export class Backups {
private busy: Promise<unknown> = Promise.resolve();
readonly where: Where;
private run: Runner;
private readonly now: () => Date;
private readonly say: (line: string) => void;
constructor(
where: Where,
run: Runner = execRunner,
now: () => Date = () => new Date(),
say: (line: string) => void = (l) => console.error(`[restic] ${l}`),
) {
this.where = where;
this.run = run;
this.now = now;
this.say = say;
}
private restic(args: string[]): Promise<string> {
return this.run("restic", ["--repo", this.where.repository, "--password-file", this.where.passwordFile, "--no-cache", ...args]);
}
/** One thing at a time: two nights, or a night and a restore, never share a dump. */
private serial<T>(work: () => Promise<T>): Promise<T> {
const next = this.busy.then(work, work);
this.busy = next.catch(() => undefined);
return next;
}
declared(): Declared[] {
return parseDeclared(readFileSync(this.where.declared, "utf8"));
}
private nightsFile(): string {
return join(this.where.state, "nights.json");
}
nights(): Record<string, Night> {
try {
return JSON.parse(readFileSync(this.nightsFile(), "utf8")) as Record<string, Night>;
} catch {
return {};
}
}
private record(module: string, night: Night): void {
const all = this.nights();
all[module] = night;
writeFileSync(this.nightsFile(), JSON.stringify(all, null, 2) + "\n", { mode: 0o600 });
}
/** The repository, made the first time. A repository that exists and cannot be opened is said,
* never replaced: replacing it would discard every restore point to fix a password. */
async ensureRepository(): Promise<void> {
try {
await this.restic(["cat", "config"]);
} catch (err) {
const why = String((err as Error).message);
if (!/does not exist|unable to open config file|Is there a repository at the following location/i.test(why)) {
throw new Error(`the repository at ${this.where.repository} cannot be opened, and is left as it is: ${why}`);
}
this.say(`no repository at ${this.where.repository}; making one`);
await this.restic(["init"]);
}
}
/** One module's night: its run lines, then one snapshot of its paths. A failure is that module's. */
private async one(d: Declared): Promise<Night> {
const at = this.now().toISOString();
try {
for (const command of d.runs) {
await this.run("sh", ["-c", command]);
}
const missing = d.paths.filter((p) => !existsSync(p));
if (missing.length > 0) throw new Error(`${missing.join(", ")} does not exist`);
if (d.paths.length === 0) throw new Error("it runs a dump and names no directory to keep it from");
const out = await this.restic(["backup", "--json", "--tag", tagOf(d.module), ...d.paths]);
const summary = out
.split("\n")
.map((l) => { try { return JSON.parse(l) as { message_type?: string; snapshot_id?: string }; } catch { return {}; } })
.find((m) => m.message_type === "summary");
const night: Night = { ok: true, at, snapshot: summary?.snapshot_id?.slice(0, 8) };
this.say(`${d.module}: backed up (${night.snapshot ?? "no snapshot id reported"})`);
return night;
} catch (err) {
const night: Night = { ok: false, at, error: String((err as Error).message) };
this.say(`${d.module}: NOT backed up: ${night.error}`);
return night;
}
}
/** A night: every module, or one, then the rotation. Returns each module's outcome. */
backUp(only?: string): Promise<Record<string, Night>> {
return this.serial(async () => {
await this.ensureRepository();
const all = this.declared();
const chosen = only ? all.filter((d) => d.module === only) : all;
if (only && chosen.length === 0) {
throw new Error(`${only} declares nothing to back up on this machine; it backs up ${all.map((d) => d.module).join(", ") || "nothing"}`);
}
const outcome: Record<string, Night> = {};
for (const d of chosen) {
outcome[d.module] = await this.one(d);
this.record(d.module, outcome[d.module]);
}
await this.restic([
"forget", "--prune", "--group-by", "host,tags",
"--keep-daily", String(KEEP.daily), "--keep-weekly", String(KEEP.weekly), "--keep-monthly", String(KEEP.monthly),
]).catch((err) => this.say(`thinning the restore points failed, and every one is kept: ${(err as Error).message}`));
return outcome;
});
}
async snapshots(module?: string): Promise<Snapshot[]> {
const args = ["snapshots", "--json"];
if (module) args.push("--tag", tagOf(module));
return JSON.parse((await this.restic(args)) || "[]") as Snapshot[];
}
/** What is backed up here: each module, what it declared, its last night and its restore points. */
async backedUp(module?: string) {
const nights = this.nights();
const snaps = await this.snapshots(module).catch(() => [] as Snapshot[]);
return this.declared()
.filter((d) => !module || d.module === module)
.map((d) => {
const mine = snaps.filter((s) => (s.tags ?? []).includes(tagOf(d.module)));
return {
module: d.module,
runs: d.runs.length,
paths: d.paths,
lastNight: nights[d.module] ?? null,
restorePoints: mine.length,
newest: mine.length ? { snapshot: mine[mine.length - 1].short_id, at: mine[mine.length - 1].time } : null,
};
});
}
/** A module's data from a restore point, BESIDE the live data: each directory as
* <path>.restored-<stamp>. A target that already exists is refused, never overwritten. */
restore(module: string, snapshot?: string, path?: string) {
return this.serial(async () => {
const mine = await this.snapshots(module);
if (mine.length === 0) throw new Error(`${module} has no restore point on this machine`);
const chosen = snapshot ? mine.find((s) => s.id.startsWith(snapshot) || s.short_id === snapshot) : mine[mine.length - 1];
if (!chosen) {
throw new Error(`${module} has no restore point ${snapshot}; it has ${mine.map((s) => `${s.short_id} (${s.time})`).join(", ")}`);
}
const paths = path ? chosen.paths.filter((p) => p === path) : chosen.paths;
if (paths.length === 0) throw new Error(`restore point ${chosen.short_id} of ${module} holds ${chosen.paths.join(", ")}, not ${path}`);
const stamp = this.now().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
const restored: string[] = [];
for (const p of paths) {
const target = `${p}.restored-${stamp}`;
if (existsSync(target)) throw new Error(`${target} already exists; nothing is restored over anything`);
await this.restic(["restore", `${chosen.id}:${p}`, "--target", target]);
restored.push(target);
this.say(`${module}: restored ${p} from ${chosen.short_id} to ${target}`);
}
return { module, from: { snapshot: chosen.short_id, at: chosen.time }, restored, live: "untouched — swapping it in is a person's act" };
});
}
/** The weekly look at the repository's own integrity, with a sample of the data read back. */
check(): Promise<string> {
return this.serial(() => this.restic(["check", "--read-data-subset", "5%"]));
}
}
/** When the next night is due: the given hour, local time, today if it is still ahead, else tomorrow. */
export function nextNight(now: Date, hour: number): Date {
const next = new Date(now);
next.setHours(hour, 0, 0, 0);
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
return next;
}
/** Whether a night was missed: the newest good night of any module is older than a day and a bit —
* the machine was off, or this module was not running, at the hour. */
export function missedANight(nights: Record<string, Night>, now: Date): boolean {
const good = Object.values(nights).filter((n) => n.ok).map((n) => Date.parse(n.at));
if (good.length === 0) return true;
return now.getTime() - Math.max(...good) > 26 * 3600 * 1000;
}