Path: blob/master/src/packages/file-server/btrfs/subvolume-bup.ts
1539 views
/*12BUP Architecture:34There is a single global dedup'd backup archive stored in the btrfs filesystem.5Obviously, admins should rsync this regularly to a separate location as a genuine6backup strategy.78NOTE: we use bup instead of btrfs send/recv !910Not used. Instead we will rely on bup (and snapshots of the underlying disk) for backups, since:11- much easier to check they are valid12- decoupled from any btrfs issues13- not tied to any specific filesystem at all14- easier to offsite via incremental rsync15- much more space efficient with *global* dedup and compression16- bup is really just git, which is much more proven than even btrfs1718The drawback is speed, but that can be managed.19*/2021import { type DirectoryListingEntry } from "@cocalc/util/types";22import { type Subvolume } from "./subvolume";23import { sudo, parseBupTime } from "./util";24import { join, normalize } from "path";25import getLogger from "@cocalc/backend/logger";2627const BUP_SNAPSHOT = "temp-bup-snapshot";2829const logger = getLogger("file-server:btrfs:subvolume-bup");3031export class SubvolumeBup {32constructor(private subvolume: Subvolume) {}3334// create a new bup backup35save = async ({36// timeout used for bup index and bup save commands37timeout = 30 * 60 * 1000,38}: { timeout?: number } = {}) => {39if (await this.subvolume.snapshots.exists(BUP_SNAPSHOT)) {40logger.debug(`createBupBackup: deleting existing ${BUP_SNAPSHOT}`);41await this.subvolume.snapshots.delete(BUP_SNAPSHOT);42}43try {44logger.debug(45`createBackup: creating ${BUP_SNAPSHOT} to get a consistent backup`,46);47await this.subvolume.snapshots.create(BUP_SNAPSHOT);48const target = this.subvolume.normalize(49this.subvolume.snapshots.path(BUP_SNAPSHOT),50);5152logger.debug(`createBupBackup: indexing ${BUP_SNAPSHOT}`);53await sudo({54command: "bup",55args: [56"-d",57this.subvolume.filesystem.bup,58"index",59"--exclude",60join(target, ".snapshots"),61"-x",62target,63],64timeout,65});6667logger.debug(`createBackup: saving ${BUP_SNAPSHOT}`);68await sudo({69command: "bup",70args: [71"-d",72this.subvolume.filesystem.bup,73"save",74"--strip",75"-n",76this.subvolume.name,77target,78],79timeout,80});81} finally {82logger.debug(`createBupBackup: deleting temporary ${BUP_SNAPSHOT}`);83await this.subvolume.snapshots.delete(BUP_SNAPSHOT);84}85};8687restore = async (path: string) => {88// path -- branch/revision/path/to/dir89if (path.startsWith("/")) {90path = path.slice(1);91}92path = normalize(path);93// ... but to avoid potential data loss, we make a snapshot before deleting it.94await this.subvolume.snapshots.create();95const i = path.indexOf("/"); // remove the commit name96// remove the target we're about to restore97await this.subvolume.fs.rm(path.slice(i + 1), { recursive: true });98await sudo({99command: "bup",100args: [101"-d",102this.subvolume.filesystem.bup,103"restore",104"-C",105this.subvolume.path,106join(`/${this.subvolume.name}`, path),107"--quiet",108],109});110};111112ls = async (path: string = ""): Promise<DirectoryListingEntry[]> => {113if (!path) {114const { stdout } = await sudo({115command: "bup",116args: ["-d", this.subvolume.filesystem.bup, "ls", this.subvolume.name],117});118const v: DirectoryListingEntry[] = [];119let newest = 0;120for (const x of stdout.trim().split("\n")) {121const name = x.split(" ").slice(-1)[0];122if (name == "latest") {123continue;124}125const mtime = parseBupTime(name).valueOf() / 1000;126newest = Math.max(mtime, newest);127v.push({ name, isdir: true, mtime });128}129if (v.length > 0) {130v.push({ name: "latest", isdir: true, mtime: newest });131}132return v;133}134135path = normalize(path);136const { stdout } = await sudo({137command: "bup",138args: [139"-d",140this.subvolume.filesystem.bup,141"ls",142"--almost-all",143"--file-type",144"-l",145join(`/${this.subvolume.name}`, path),146],147});148const v: DirectoryListingEntry[] = [];149for (const x of stdout.split("\n")) {150// [-rw-------","6b851643360e435eb87ef9a6ab64a8b1/6b851643360e435eb87ef9a6ab64a8b1","5","2025-07-15","06:12","a.txt"]151const w = x.split(/\s+/);152if (w.length >= 6) {153let isdir, name;154if (w[5].endsWith("@") || w[5].endsWith("=") || w[5].endsWith("|")) {155w[5] = w[5].slice(0, -1);156}157if (w[5].endsWith("/")) {158isdir = true;159name = w[5].slice(0, -1);160} else {161name = w[5];162isdir = false;163}164const size = parseInt(w[2]);165const mtime = new Date(w[3] + "T" + w[4]).valueOf() / 1000;166v.push({ name, size, mtime, isdir });167}168}169return v;170};171172prune = async ({173dailies = "1w",174monthlies = "4m",175all = "3d",176}: { dailies?: string; monthlies?: string; all?: string } = {}) => {177await sudo({178command: "bup",179args: [180"-d",181this.subvolume.filesystem.bup,182"prune-older",183`--keep-dailies-for=${dailies}`,184`--keep-monthlies-for=${monthlies}`,185`--keep-all-for=${all}`,186"--unsafe",187this.subvolume.name,188],189});190};191}192193194