Archive the whole data volume in Backups, not just the database
Settings > Backups gains a whole-data archive beside the database snapshots, and both lists now fully work from the page. - Data archives: `write-data-backup-archive.ts` tars all of DATA_DIR into BACKUP_ARCHIVE_DIR as cobble-data-<epoch-ms>.tar.gz, every entry under one root folder named after the archive, writing a .part name and renaming on success so a cut-off run is never listed. The newest AppData.DataBackupsKept are kept; db/.backups is left as it is. - Restore: `restore-data-backup.ts` extracts to a temp dir, requires one root folder holding a db folder, takes a safety archive first, then replaces DATA_DIR (the database snapshots included) and `restart-app.ts` restarts the process through the docker socket, since the open SQLite handle points at the replaced file. - Upload and restore: a modal over each list (database file or data archive), replacing the old /admin/settings/backups/restore page. - Delete for both kinds, icon-only row actions shared through RowIconButton, and restore on every row. - One backup or restore of either kind at a time (with-backup-run-lock). - BACKUP_ARCHIVE_DIR is a start-up path (ProcessOnlyEnvKeys); the installer mounts $COBBLE_HOME/backups-archives at /backups-archives and recreates a container that lacks it. The image installs tar and gzip.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import writeDataBackupArchive from "./write-data-backup-archive";
|
||||
import resolveDataBackupPath from "./resolve-data-backup-path";
|
||||
import withBackupRunLock from "./with-backup-run-lock";
|
||||
|
||||
export type DataBackupRestoreResult = {
|
||||
/**
|
||||
* The archive taken of the data as it was before the restore, so the
|
||||
* restore itself can be undone.
|
||||
*/
|
||||
safety_name: string;
|
||||
restored_name: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
backup_name?: string;
|
||||
};
|
||||
|
||||
type RootParams = {
|
||||
/**
|
||||
* Where the archive was extracted to.
|
||||
*/
|
||||
staging_dir: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single folder an archive holds its data in (its root folder, named
|
||||
* after the archive when it was made). An archive with no single root
|
||||
* folder, or with loose files beside it, isn't one of ours.
|
||||
*/
|
||||
function findArchiveRoot({ staging_dir }: RootParams) {
|
||||
const entries = fs.readdirSync(staging_dir);
|
||||
const folders = entries.filter((entry) =>
|
||||
fs.statSync(path.join(staging_dir, entry)).isDirectory(),
|
||||
);
|
||||
|
||||
const [folder] = folders;
|
||||
|
||||
if (entries.length != 1 || !folder) {
|
||||
throw new Error(
|
||||
`The archive has no single root folder: it isn't a Cobble data archive`,
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(staging_dir, folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole `DATA_DIR` with the contents of a data archive:
|
||||
* the database, media, computer homes, bot and workspace files, and the
|
||||
* database snapshots in `db/.backups` (which the archive's own overwrite,
|
||||
* so the restore is one consistent set).
|
||||
*
|
||||
* The archive is read into a temporary directory on the same volume
|
||||
* first, must hold one root folder with a database in it, and nothing on
|
||||
* disk is touched before that passes. A fresh archive of the current data
|
||||
* is taken as a safety copy, then the data directory is emptied (the
|
||||
* database snapshots included) and filled from the archive.
|
||||
*
|
||||
* The caller restarts the process afterwards: the open SQLite handle still
|
||||
* points at the replaced file, so nothing may keep reading it.
|
||||
*/
|
||||
export default async function restoreDataBackup({
|
||||
backup_name,
|
||||
}: Params): Promise<DataBackupRestoreResult> {
|
||||
return await withBackupRunLock({
|
||||
task: async () => {
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
const archive_path = resolveDataBackupPath({ backup_name });
|
||||
const archive_name = path.basename(archive_path);
|
||||
const staging_dir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "cobble-restore-"),
|
||||
);
|
||||
|
||||
try {
|
||||
const extract = Bun.spawn(
|
||||
["tar", "-xzf", archive_path, "-C", staging_dir],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr, exit_code] = await Promise.all([
|
||||
new Response(extract.stdout).text(),
|
||||
new Response(extract.stderr).text(),
|
||||
extract.exited,
|
||||
]);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
throw new Error(
|
||||
stderr.trim() ||
|
||||
stdout.trim() ||
|
||||
`tar failed (exit ${exit_code})`,
|
||||
);
|
||||
}
|
||||
|
||||
// The archive's own name may differ from its file name: an
|
||||
// upload is saved under a fresh timestamp. Its one root
|
||||
// folder is what holds the data, so find it rather than
|
||||
// assume it matches.
|
||||
const source_dir = findArchiveRoot({ staging_dir });
|
||||
|
||||
if (!fs.existsSync(path.join(source_dir, "db"))) {
|
||||
throw new Error(
|
||||
`The archive has no database, so nothing was restored`,
|
||||
);
|
||||
}
|
||||
|
||||
// The archive's snapshots would otherwise vanish with the
|
||||
// current ones, so the safety copy is taken before them.
|
||||
// The run lock is already held here, hence the unlocked
|
||||
// writer rather than `createDataBackup`.
|
||||
const safety = await writeDataBackupArchive();
|
||||
|
||||
// Everything goes, the snapshots in `db/.backups` included:
|
||||
// the archive's own set replaces them.
|
||||
for (const entry of fs.readdirSync(DATA_DIR)) {
|
||||
fs.rmSync(path.join(DATA_DIR, entry), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(source_dir)) {
|
||||
fs.cpSync(
|
||||
path.join(source_dir, entry),
|
||||
path.join(DATA_DIR, entry),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
safety_name: safety.name,
|
||||
restored_name: archive_name,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(staging_dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user