clawdie-ai/scripts/backup.ts
Sam & Claude 2ed6245b11 feat(backup): add backup script and restore runbook
npm run backup exports all critical state to a portable tarball:
  - messages.db (SQLite — all chats, tasks, sessions)
  - memory_db.sql + skills_db.sql (pg_dump from db jail)
  - .env, groups/, mount-allowlist.json

Takes ZFS snapshots via hostd before export. Flags:
  --skip-skills   skip skills_db (large, regenerable)
  --output <dir>  write archive to specific directory
  --no-snapshot   skip ZFS snapshots

setup/sanoid.ts: add management jail dataset to snapshot retention policy.
docs/sessions/2026-03-16-backup-restore.md: full restore runbook covering
SQLite, PostgreSQL, ZFS rollback, hardware migration, and cron automation.

---
Build: pass | Tests: pass — 489 passed (48 files)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---
Build: pass | Tests: pass — Tests  489 passed | 10 skipped (499)
2026-03-16 11:17:46 +00:00

261 lines
11 KiB
TypeScript

/**
* Clawdie backup — exports all critical state to a portable tarball.
*
* What's included:
* messages.db — SQLite: all 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 postgresql17-client on host if missing)
* Restore: see docs/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_NAME,
MEMORY_DB_NAME,
MEMORY_DB_URL,
MOUNT_ALLOWLIST_PATH,
SKILLS_DB_NAME,
SKILLS_DB_URL,
STORE_DIR,
GROUPS_DIR,
ZFS_PREFIX,
} from '../src/config.js';
import { formatDisplayDate } from '../src/display-date.js';
import { hostd } from '../src/hostd/client.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();
/** DD.mmm.YYYY-HHMM — used in filenames and snapshot names */
function backupStamp(date: Date): string {
const d = String(date.getDate()).padStart(2, '0');
const months = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
const m = months[date.getMonth()];
const y = date.getFullYear();
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
return `${d}.${m}.${y}-${hh}${mm}`;
}
const STAMP = backupStamp(now);
const BACKUP_NAME = `${AGENT_NAME}-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) ──────────────────────────────────────────────────
async function takeZfsSnapshots(snapshotTag: string): Promise<void> {
const datasets = [
`zroot/${ZFS_PREFIX}/jails/db`,
`zroot/${ZFS_PREFIX}/jails/git`,
`zroot/${ZFS_PREFIX}/jails/cms`,
];
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 stagingBase = fs.mkdtempSync(path.join(os.tmpdir(), 'clawdie-backup-'));
const stagingDir = path.join(stagingBase, BACKUP_NAME);
fs.mkdirSync(stagingDir, { recursive: true });
const manifest: Record<string, unknown> = {
agent: AGENT_NAME,
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('');
}
// ── SQLite ────────────────────────────────────────────────────────────────────
console.log('Exporting SQLite...');
const sqliteSrc = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(sqliteSrc)) {
fs.copyFileSync(sqliteSrc, path.join(stagingDir, 'messages.db'));
const sizeKb = Math.round(fs.statSync(sqliteSrc).size / 1024);
ok(`messages.db (${sizeKb} KB)`);
items.push('messages.db');
} else {
warn('messages.db not found — skipping');
}
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/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);
});