clawdie-ai/scripts/backup.ts
Operator & Claude Code 00a908306d Honor configured ZFS pool everywhere
Codex caught zroot hardcodes in setup/sanoid.ts and setup/db.ts; same
pattern remained in three more shipping locations:

- scripts/backup.ts: jail and shared dataset paths
- src/tenant-registry.ts: default tenant dataset list
- setup/sanoid.ts: npm-global retention candidate

Add zfsPool() helper to maintenance-snapshots.ts (where the analogous
buildHostDbDatasets reads ZFS_POOL) and use it in all three. Operators
running on non-default pools no longer get silently-wrong dataset paths
in backup, tenant provisioning, or sanoid retention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---
Build: pass | Tests: pass — Tests  2099 passed (2099)
2026-05-02 08:20:32 +02:00

382 lines
12 KiB
TypeScript

/**
* Clawdie backup — exports all critical state to a portable tarball.
*
* What's included:
* ops_db.sql — pg_dump: operational data (chats, messages, tasks, sessions)
* memory_db.sql — pg_dump: agent memory + embeddings
* skills_db.sql — pg_dump: built-in knowledge (skip with --skip-skills)
* env — .env config (API keys, db passwords, all settings)
* groups/ — registered group configurations
* mount-allowlist.json — mount security allowlist
* manifest.json — backup metadata
*
* ZFS snapshots: taken automatically via hostd before export (skip with --no-snapshot).
* Sanoid handles ongoing automated retention; this script does on-demand export.
*
* Usage:
* npx tsx scripts/backup.ts
* npx tsx scripts/backup.ts --skip-skills --output /mnt/backup
* npx tsx scripts/backup.ts --no-snapshot
*
* Output: ~/clawdie-backup-DD.mmm.YYYY-HHMM.tar.gz
*
* Requires: pg_dump (install postgresql18-client on host if missing)
* Restore: see docs/internal/sessions/2026-03-16-backup-restore.md
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
AGENT_HOME,
MEMORY_DB_NAME,
MEMORY_DB_URL,
MOUNT_ALLOWLIST_PATH,
OPS_DB_NAME,
OPS_DB_URL,
SKILLS_DB_NAME,
SKILLS_DB_URL,
STORE_DIR,
GROUPS_DIR,
TENANT_ID,
ZFS_PREFIX,
} from '../src/config.js';
import { formatDisplayDate, formatSnapshotStamp } from '../src/display-date.js';
import { hostd } from '../src/hostd/client.js';
import { zfsPool } from '../src/maintenance-snapshots.js';
// ── CLI args ──────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const SKIP_SKILLS = args.includes('--skip-skills');
const NO_SNAPSHOT = args.includes('--no-snapshot');
const outputIdx = args.indexOf('--output');
const OUTPUT_DIR = outputIdx !== -1 ? args[outputIdx + 1] : os.homedir();
// ── Timestamp helpers ─────────────────────────────────────────────────────────
const now = new Date();
const STAMP = formatSnapshotStamp(now);
const BACKUP_NAME = `${TENANT_ID}-backup-${STAMP}`;
// ── Helpers ───────────────────────────────────────────────────────────────────
function ok(msg: string): void {
console.log(`${msg}`);
}
function warn(msg: string): void {
console.log(`${msg}`);
}
function fail(msg: string): never {
console.error(`${msg}`);
process.exit(1);
}
function run(
cmd: string,
args: string[],
env?: NodeJS.ProcessEnv,
): { ok: boolean; output: string } {
const result = spawnSync(cmd, args, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
const output = [result.stdout, result.stderr]
.filter(Boolean)
.join('\n')
.trim();
return { ok: result.status === 0 && !result.error, output };
}
function commandExists(cmd: string): boolean {
return run('which', [cmd]).ok;
}
// ── ZFS snapshots (via hostd) ──────────────────────────────────────────────────
type ZfsDatasetInfo = { name: string; mountpoint: string };
function listZfsDatasets(): ZfsDatasetInfo[] {
if (!commandExists('zfs')) return [];
const result = run('zfs', [
'list',
'-H',
'-o',
'name,mountpoint',
'-t',
'filesystem',
]);
if (!result.ok) return [];
return result.output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, mountpoint] = line.split('\t');
return { name, mountpoint };
})
.filter((entry) =>
Boolean(
entry.name && entry.mountpoint && entry.mountpoint.startsWith('/'),
),
);
}
function detectHomeDataset(
datasets: ZfsDatasetInfo[],
homePath: string,
): string | null {
let best: ZfsDatasetInfo | null = null;
for (const entry of datasets) {
if (entry.mountpoint === homePath) return entry.name;
if (
!homePath.startsWith(
entry.mountpoint.endsWith('/')
? entry.mountpoint
: `${entry.mountpoint}/`,
)
) {
continue;
}
if (!best || entry.mountpoint.length > best.mountpoint.length) {
best = entry;
}
}
return best?.name ?? null;
}
function datasetExists(datasets: ZfsDatasetInfo[], name: string): boolean {
return datasets.some((entry) => entry.name === name);
}
function buildSnapshotTargets(): string[] {
const pool = zfsPool();
const targets = [
`${pool}/${ZFS_PREFIX}/jails/${TENANT_ID}-db`,
`${pool}/${ZFS_PREFIX}/jails/${TENANT_ID}-git`,
`${pool}/${ZFS_PREFIX}/jails/${TENANT_ID}-cms`,
];
const datasets = listZfsDatasets();
const homeDataset = detectHomeDataset(datasets, AGENT_HOME);
if (homeDataset) targets.push(homeDataset);
const npmGlobalDataset = `${pool}/${ZFS_PREFIX}/shared/npm-global`;
if (datasetExists(datasets, npmGlobalDataset)) {
targets.push(npmGlobalDataset);
}
return targets;
}
async function takeZfsSnapshots(snapshotTag: string): Promise<void> {
const datasets = buildSnapshotTargets();
for (const dataset of datasets) {
try {
const res = await hostd('zfs-snapshot', { dataset, name: snapshotTag });
if (res.ok) {
ok(`ZFS snapshot: ${dataset}@${snapshotTag}`);
} else {
warn(`ZFS snapshot failed for ${dataset}: ${res.error ?? res.output}`);
}
} catch {
warn(`hostd unreachable — ZFS snapshot skipped for ${dataset}`);
break;
}
}
}
// ── pg_dump ───────────────────────────────────────────────────────────────────
function pgDump(url: string, dbName: string, destFile: string): boolean {
if (!commandExists('pg_dump')) {
warn(
`pg_dump not found — skipping ${dbName} (install postgresql-client on host)`,
);
return false;
}
const result = run('pg_dump', [
'--dbname',
url,
'--no-password',
'--clean',
'--if-exists',
'-f',
destFile,
]);
if (result.ok) {
const sizeKb = Math.round(fs.statSync(destFile).size / 1024);
ok(`pg_dump ${dbName}${path.basename(destFile)} (${sizeKb} KB)`);
return true;
} else {
warn(`pg_dump ${dbName} failed: ${result.output.slice(0, 200)}`);
return false;
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log('');
console.log(`Clawdie backup — ${formatDisplayDate(now)}`);
console.log(`Output: ${OUTPUT_DIR}/${BACKUP_NAME}.tar.gz`);
console.log('');
// ── Staging dir ─────────────────────────────────────────────────────────────
const tmpBase = path.join(process.cwd(), 'tmp', 'backup');
fs.mkdirSync(tmpBase, { recursive: true });
const stagingBase = fs.mkdtempSync(path.join(tmpBase, `${TENANT_ID}-backup-`));
const stagingDir = path.join(stagingBase, BACKUP_NAME);
fs.mkdirSync(stagingDir, { recursive: true });
const manifest: Record<string, unknown> = {
agent: TENANT_ID,
created_at: now.toISOString(),
created_display: formatDisplayDate(now),
stamp: STAMP,
items: [] as string[],
};
const items = manifest.items as string[];
// ── ZFS snapshots ────────────────────────────────────────────────────────────
if (!NO_SNAPSHOT) {
console.log('Taking ZFS snapshots...');
// Use snapshot stamp format per AGENTS.md: DD.mmm.YYYY-HHMM
const snapshotTag = `pre-backup-${STAMP}`;
await takeZfsSnapshots(snapshotTag);
console.log('');
}
// ── PostgreSQL dumps ──────────────────────────────────────────────────────────
console.log('Exporting PostgreSQL...');
if (pgDump(OPS_DB_URL, OPS_DB_NAME, path.join(stagingDir, 'ops_db.sql'))) {
items.push('ops_db.sql');
}
if (
pgDump(
MEMORY_DB_URL,
MEMORY_DB_NAME,
path.join(stagingDir, 'memory_db.sql'),
)
) {
items.push('memory_db.sql');
}
console.log('');
// ── PostgreSQL dumps ──────────────────────────────────────────────────────────
console.log('Exporting PostgreSQL...');
if (
pgDump(
MEMORY_DB_URL,
MEMORY_DB_NAME,
path.join(stagingDir, 'memory_db.sql'),
)
) {
items.push('memory_db.sql');
}
if (!SKIP_SKILLS) {
if (
pgDump(
SKILLS_DB_URL,
SKILLS_DB_NAME,
path.join(stagingDir, 'skills_db.sql'),
)
) {
items.push('skills_db.sql');
}
} else {
ok('skills_db skipped (--skip-skills)');
}
console.log('');
// ── Config ────────────────────────────────────────────────────────────────────
console.log('Exporting config...');
const envSrc = path.join(process.cwd(), '.env');
if (fs.existsSync(envSrc)) {
fs.copyFileSync(envSrc, path.join(stagingDir, 'env'));
ok('.env');
items.push('env');
} else {
warn('.env not found');
}
const groupsSrc = GROUPS_DIR;
if (fs.existsSync(groupsSrc)) {
const dest = path.join(stagingDir, 'groups');
fs.mkdirSync(dest);
for (const f of fs.readdirSync(groupsSrc)) {
fs.copyFileSync(path.join(groupsSrc, f), path.join(dest, f));
}
ok('groups/');
items.push('groups/');
}
if (fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
fs.copyFileSync(
MOUNT_ALLOWLIST_PATH,
path.join(stagingDir, 'mount-allowlist.json'),
);
ok('mount-allowlist.json');
items.push('mount-allowlist.json');
} else {
ok('mount-allowlist.json not found — skipping');
}
console.log('');
// ── Manifest ──────────────────────────────────────────────────────────────────
fs.writeFileSync(
path.join(stagingDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
);
// ── Tarball ───────────────────────────────────────────────────────────────────
console.log('Creating archive...');
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const tarPath = path.join(OUTPUT_DIR, `${BACKUP_NAME}.tar.gz`);
const tarResult = run('tar', [
'-czf',
tarPath,
'-C',
stagingBase,
BACKUP_NAME,
]);
if (!tarResult.ok) fail(`tar failed: ${tarResult.output}`);
const sizeMb = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(2);
ok(`${BACKUP_NAME}.tar.gz (${sizeMb} MB)`);
// ── Cleanup ───────────────────────────────────────────────────────────────────
fs.rmSync(stagingBase, { recursive: true, force: true });
console.log('');
console.log('─'.repeat(60));
console.log(` Backup complete: ${tarPath}`);
console.log(` Items: ${items.join(', ')}`);
console.log(
` Restore: see docs/internal/sessions/2026-03-16-backup-restore.md`,
);
console.log('─'.repeat(60));
console.log('');
}
main().catch((err) => {
console.error(
'Backup failed:',
err instanceof Error ? err.message : String(err),
);
process.exit(1);
});