/** * Staging of celilo's own state for a backup hook * (openspec/changes/hook-process-boundary, design D9b). * * celilo-mgmt's `on_backup` used to reach into celilo's data directory and * copy `master.key`, the DB, the fleet `.ssh` and every other module's * source tree out of it. Measured across the whole hook, it read none of * those bytes: every one was a `copyFileSync` / `cpSync` into `backup_dir`. * It does not need those files in its filesystem view. It needs them to end * up in the backup. * * So the framework copies them into a directory it creates and hands over, * exactly as `materializeCrossModuleRoot` already does for * `cross_module_read`. The hook reads from a staged location it was given, * celilo-mgmt is fully jailed, and "the jail applies to every module" stays * true with no exemption to audit. * * The layout mirrors what `on_backup` puts in the envelope, so the hook's * remaining job is a copy: * * /celilo.db WAL-correct snapshot * /master.key if present * /ssh/ fleet keypair, if present * /module_src// each module's lean source */ import { Database } from 'bun:sqlite'; import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync, } from 'node:fs'; import { join } from 'node:path'; import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths'; import { getFleetSshDir } from './fleet-key'; /** * Consistent SQLite snapshot via bun:sqlite's serialize(). * * celilo runs the DB in WAL mode (apps/celilo/src/db/client.ts), so committed * rows live in `celilo.db-wal` until a checkpoint folds them into the main * file. The main file is routinely a single near-empty page while ALL the * real data (20+ tables, modules, config, secrets) sits in the WAL. A readonly * connection reads THROUGH the WAL, so serialize() captures the full committed * state into one standalone file — exactly what restore needs. * * An earlier implementation shelled out to `sqlite3 ".backup"` and fell back * to a plain copyFileSync when the CLI was absent. On a deb-installed box * there IS no sqlite3 CLI, so the fallback ran — and a plain copy of the main * file alone DROPS the WAL, producing a silently EMPTY backup (restore then * installs an empty DB). bun:sqlite is a Bun built-in and reads the WAL * correctly — no CLI dependency, no data loss. * * This lives in the framework rather than in a module because celilo owns the * schema and `getDbPath()`, and because a bug that silently empties backups * should be fixed once, where it is tested. */ export function snapshotDatabase(srcPath: string, destPath: string): void { const db = new Database(srcPath, { readonly: true }); try { writeFileSync(destPath, db.serialize()); } finally { db.close(); } } /** * Directories never worth capturing from a module's source tree. Every one * is rebuilt on deploy (`module build` / `generate`) or re-vendored on * restore (`installScriptDependencies`). */ const EXCLUDE_DIRS = new Set([ 'node_modules', 'generated', 'dist', 'coverage', 'coverage-raw', '.git', 'screenshots', 'e2e', ]); /** * Size ceiling for a single captured source file. * * celilo module dirs bundle large BUILD artifacts (compiled binaries, built * assets, `*.netapp` packages) — turnip's were ~1.6 GB, which made the * in-memory tar+encrypt segfault. Excluding by directory name misses the ones * that sit outside a known build dir, so a size cap catches them generically. */ const MAX_SRC_FILE_BYTES = 2 * 1024 * 1024; export interface StagedSystemState { /** The directory the caller passes to the hook. */ root: string; masterKeyStaged: boolean; fleetSshStaged: boolean; moduleSourceCount: number; /** Files the size cap or the `.netapp` rule dropped, named. No silent caps. */ skippedLarge: string[]; } /** * Populate `rootDir` with celilo's own state and return what landed there. * * Absent pieces are reported rather than thrown on: a box with no fleet key * yet is an ordinary pre-deploy state, and a missing `master.key` is a fact * the caller surfaces to the operator (secrets in the snapshot would be * unreadable on restore) rather than a reason to abort the backup. */ export function stageSystemState(rootDir: string): StagedSystemState { mkdirSync(rootDir, { recursive: true }); snapshotDatabase(getDbPath(), join(rootDir, 'celilo.db')); // `getMasterKeyPath()` honours CELILO_MASTER_KEY_PATH and otherwise sits // under getDataDir(). The hook used to re-derive it as // `dirname(db_path)/master.key`, which is the same file on a deb install // and a different one whenever CELILO_DB_PATH points elsewhere. const masterKeyPath = getMasterKeyPath(); const masterKeyStaged = existsSync(masterKeyPath); if (masterKeyStaged) { copyFileSync(masterKeyPath, join(rootDir, 'master.key')); } // The private half too: the DB carries only `ssh.public_key`, so without // it a restored box cannot reach the fleet, and re-keying means // re-authorizing every managed machine. const fleetSshDir = getFleetSshDir(); const fleetSshStaged = existsSync(join(fleetSshDir, 'id_ed25519')); if (fleetSshStaged) { cpSync(fleetSshDir, join(rootDir, 'ssh'), { recursive: true }); } const { moduleSourceCount, skippedLarge } = stageModuleSources(join(rootDir, 'module_src')); return { root: rootDir, masterKeyStaged, fleetSshStaged, moduleSourceCount, skippedLarge }; } /** * Capture each module's SOURCE (manifest, scripts, ansible, templates) into * `destDir`, minus build artifacts. * * The DB references every module by `source_path`, but the rest of the * envelope carries only DB + state, not module CODE. Restoring onto a fresh * box — especially a different OS, where the source box's absolute * `source_path` does not exist — would otherwise leave it unable to deploy * ANY module, including non-registry ones (e.g. lunacycle) that a * re-import-from-registry cannot recover. * * Reads `getModuleStoragePath()`, which is where `applyStagedSystemFiles` * lays the source back down on restore. The hook derived * `dirname(db_path)/modules` instead — the same directory on a deb install, * and a different one otherwise, so backup and restore could disagree about * where module source lives. */ function stageModuleSources(destDir: string): { moduleSourceCount: number; skippedLarge: string[]; } { const modulesSrcDir = getModuleStoragePath(); const skippedLarge: string[] = []; let moduleSourceCount = 0; if (!existsSync(modulesSrcDir)) { return { moduleSourceCount, skippedLarge }; } for (const entry of readdirSync(modulesSrcDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const srcModuleDir = join(modulesSrcDir, entry.name); cpSync(srcModuleDir, join(destDir, entry.name), { recursive: true, filter: (src: string) => { const rel = src.slice(srcModuleDir.length).replace(/^\//, ''); if (rel === '') return true; // module root if (rel.split('/').some((seg) => EXCLUDE_DIRS.has(seg))) return false; const st = statSync(src); if (st.isDirectory()) return true; if (src.endsWith('.netapp')) return false; if (st.size > MAX_SRC_FILE_BYTES) { skippedLarge.push(`${entry.name}/${rel} (${(st.size / 1048576).toFixed(1)}MB)`); return false; } return true; }, }); moduleSourceCount += 1; } return { moduleSourceCount, skippedLarge }; }