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:
2026-10-22 11:00:37 +01:00
parent fd75111bb1
commit a88898d169
56 changed files with 1686 additions and 261 deletions
@@ -0,0 +1,86 @@
import fs from "fs";
import path from "path";
import { AppData } from "@/src/data/app-data";
import grabDirNames from "@/src/utils/grab-dir-names";
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
import grabDataBackups from "./grab-data-backups";
export type CreatedDataBackup = {
name: string;
path: string;
};
/**
* Writes one archive of the whole `DATA_DIR` and prunes the old ones.
* Callers already hold the backup run lock (`withBackupRunLock`), so a
* restore can take its safety copy without deadlocking on it.
*/
export default async function writeDataBackupArchive(): Promise<CreatedDataBackup> {
const { DATA_DIR } = grabDirNames();
const archive_dir = grabBackupArchiveDir();
const archive_name = `${AppData.DataBackupNamePrefix}${Date.now()}.tar.gz`;
const archive_root = archive_name.replace(/\.tar\.gz$/, "");
const archive_path = path.join(archive_dir, archive_name);
const part_path = `${archive_path}.part`;
try {
const proc = Bun.spawn(
[
"tar",
"-czf",
part_path,
// Every member (`.`, `./db`, `./media/x`, ...) is moved
// under one folder named after the archive, so an extract
// drops a single folder rather than a heap of files.
// Two expressions: the root `.` itself and every `./`
// member, so the whole archive sits under one folder named
// after it and an extract drops a single folder.
"--transform",
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`,
"-C",
DATA_DIR,
".",
],
{
cwd: process.cwd(),
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr, exit_code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exit_code !== 0) {
throw new Error(
stderr.trim() ||
stdout.trim() ||
`tar failed (exit ${exit_code})`,
);
}
// Only a finished archive takes its real name, so a failed or
// cut-off run is never listed or downloaded.
fs.renameSync(part_path, archive_path);
} finally {
if (fs.existsSync(part_path)) {
fs.rmSync(part_path, { force: true });
}
}
const old = grabDataBackups().slice(AppData.DataBackupsKept);
for (const backup of old) {
fs.rmSync(path.join(archive_dir, backup.name), { force: true });
}
return {
name: archive_name,
path: archive_path,
};
}