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)
382 lines
12 KiB
TypeScript
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);
|
|
});
|