feat(webui): tar.gz backup archive module with prune and mutex

This commit is contained in:
m4
2026-08-11 10:40:10 +08:00
parent 1532a9de3e
commit 72dedba64e
2 changed files with 197 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backup-"));
const backendDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backend-"));
const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR;
process.env.EVOSCIENTIST_DATA_DIR = dataDir;
const backup = await import("./backup");
const { getSystemConfig, saveSystemConfig, resetSystemConfigCacheForTests } =
await import("./systemConfig");
describe("backup", () => {
beforeEach(() => {
fs.rmSync(backup.backupsDir(), { recursive: true, force: true });
const config = getSystemConfig();
config.backup.backendDataDir = backendDir;
saveSystemConfig(config);
resetSystemConfigCacheForTests();
});
afterAll(() => {
if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR;
else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
fs.rmSync(backendDir, { recursive: true, force: true });
});
it("creates a tar.gz containing webui + backend data, excluding backups/", async () => {
fs.writeFileSync(path.join(dataDir, "webui-users.sqlite3"), "users");
fs.mkdirSync(backup.backupsDir(), { recursive: true });
fs.writeFileSync(path.join(backup.backupsDir(), "stale.txt"), "x");
fs.writeFileSync(path.join(backendDir, "memory.md"), "memory");
const entry = await backup.createBackup("manual");
expect(entry.name).toMatch(/^backup-\d{8}T\d{6}-manual\.tar\.gz$/);
expect(entry.size).toBeGreaterThan(0);
const listing = execFileSync("tar", [
"-tzf",
path.join(backup.backupsDir(), entry.name),
]).toString();
expect(listing).toContain("webui-users.sqlite3");
expect(listing).toContain("memory.md");
expect(listing).not.toContain("stale.txt");
});
it("validates names and rejects traversal", () => {
expect(backup.isValidBackupName("backup-20260811T073000-auto.tar.gz")).toBe(true);
expect(backup.isValidBackupName("../etc/passwd")).toBe(false);
expect(backup.isValidBackupName("backup.tar.gz")).toBe(false);
expect(() => backup.backupFilePath("..%2f..")).toThrow();
});
it("lists newest first and prunes beyond keep count", async () => {
const dir = backup.backupsDir();
fs.mkdirSync(dir, { recursive: true });
const names = [
"backup-20260801T000000-manual.tar.gz",
"backup-20260802T000000-auto.tar.gz",
"backup-20260803T000000-manual.tar.gz",
];
for (const name of names) fs.writeFileSync(path.join(dir, name), "x");
fs.writeFileSync(path.join(dir, "ignore-me.txt"), "x");
expect(backup.listBackups().map((e) => e.name)).toEqual([...names].reverse());
expect(backup.pruneBackups(2)).toEqual([names[0]]);
expect(backup.listBackups().map((e) => e.name)).toEqual([...names].reverse().slice(0, 2));
});
it("shares one in-flight run across concurrent callers", async () => {
const [a, b] = await Promise.all([
backup.createBackup("manual"),
backup.createBackup("auto"),
]);
expect(a.name).toBe(b.name);
});
});
+114
View File
@@ -0,0 +1,114 @@
import "server-only";
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { webuiDataDir } from "./dataDir";
import { getSystemConfig } from "./systemConfig";
const execFileAsync = promisify(execFile);
export interface BackupEntry {
name: string;
size: number;
createdAt: string;
origin: "manual" | "auto";
}
const NAME_RE = /^backup-(\d{8}T\d{6})-(manual|auto)\.tar\.gz$/;
export function backupsDir(): string {
return path.join(webuiDataDir(), "backups");
}
export function isValidBackupName(name: string): boolean {
return NAME_RE.test(name);
}
export function backupFilePath(name: string): string {
if (!isValidBackupName(name)) throw new Error("Invalid backup file name.");
return path.join(backupsDir(), name);
}
function timestamp(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
`T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
);
}
let running: Promise<BackupEntry> | null = null;
export function createBackup(origin: "manual" | "auto"): Promise<BackupEntry> {
if (running) return running;
running = runBackup(origin).finally(() => {
running = null;
});
return running;
}
async function runBackup(origin: "manual" | "auto"): Promise<BackupEntry> {
const config = getSystemConfig();
const dataDir = webuiDataDir();
const outDir = backupsDir();
fs.mkdirSync(outDir, { recursive: true });
let name = `backup-${timestamp(new Date())}-${origin}.tar.gz`;
let out = path.join(outDir, name);
// Names carry second precision; a same-second collision waits one second
// out and recomputes so every archive keeps a unique, regex-valid name.
while (fs.existsSync(out)) {
await new Promise((r) => setTimeout(r, 1100));
name = `backup-${timestamp(new Date())}-${origin}.tar.gz`;
out = path.join(outDir, name);
}
const base = path.basename(dataDir);
const args = [
"-czf", out,
"--exclude", `${base}/backups`,
"-C", path.dirname(dataDir), base,
];
const backend = config.backup.backendDataDir.trim();
if (backend) {
if (!fs.existsSync(backend)) {
throw new Error(`Backend data directory does not exist: ${backend}`);
}
args.push("-C", path.dirname(backend), path.basename(backend));
}
try {
await execFileAsync("tar", args);
} catch (error) {
fs.rmSync(out, { force: true });
throw error instanceof Error ? error : new Error("Backup archive failed.");
}
const stat = fs.statSync(out);
return { name, size: stat.size, createdAt: stat.mtime.toISOString(), origin };
}
export function listBackups(): BackupEntry[] {
const dir = backupsDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter(isValidBackupName)
.map((name) => {
const stat = fs.statSync(path.join(dir, name));
return {
name,
size: stat.size,
createdAt: stat.mtime.toISOString(),
origin: name.match(NAME_RE)![2] as "manual" | "auto",
};
})
.sort((a, b) => b.name.localeCompare(a.name));
}
export function pruneBackups(keepCount: number): string[] {
const removed: string[] = [];
for (const entry of listBackups().slice(Math.max(0, keepCount))) {
fs.rmSync(backupFilePath(entry.name), { force: true });
removed.push(entry.name);
}
return removed;
}