feat(webui): tar.gz backup archive module with prune and mutex
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user