/** * Staging celilo's own state for a backup hook (design D9b of * openspec/changes/hook-process-boundary). * * These assertions used to live in celilo-mgmt-hooks.test.ts, because the * work used to live in celilo-mgmt's on_backup. They moved here with the * code: the hook no longer reads celilo's data directory, the framework * copies it into a staged directory first. */ import { Database } from 'bun:sqlite'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { resetTestDbPath } from '../test-utils/db-path'; import { snapshotDatabase, stageSystemState } from './system-state-stage'; describe('stageSystemState', () => { let dataDir: string; let stageRoot: string; let masterKeyPath: string; beforeEach(() => { dataDir = mkdtempSync(join(tmpdir(), 'celilo-system-state-')); stageRoot = join(dataDir, 'staged'); process.env.CELILO_DATA_DIR = dataDir; process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db'); const seed = new Database(join(dataDir, 'celilo.db')); seed.run('CREATE TABLE probe (id INTEGER PRIMARY KEY, v TEXT)'); seed.run("INSERT INTO probe (v) VALUES ('hello')"); seed.close(); masterKeyPath = join(dataDir, 'master.key'); writeFileSync(masterKeyPath, 'fake-master-key-32-bytes-padding!'); process.env.CELILO_MASTER_KEY_PATH = masterKeyPath; }); afterEach(() => { delete process.env.CELILO_DATA_DIR; resetTestDbPath(); delete process.env.CELILO_MASTER_KEY_PATH; rmSync(dataDir, { recursive: true, force: true }); }); it('stages the DB snapshot and master.key into the root it is given', () => { const staged = stageSystemState(stageRoot); expect(existsSync(join(stageRoot, 'celilo.db'))).toBe(true); expect(existsSync(join(stageRoot, 'master.key'))).toBe(true); expect(staged.masterKeyStaged).toBe(true); expect(staged.root).toBe(stageRoot); }); it('reports a missing master.key rather than throwing', () => { rmSync(masterKeyPath); const staged = stageSystemState(stageRoot); expect(staged.masterKeyStaged).toBe(false); expect(existsSync(join(stageRoot, 'master.key'))).toBe(false); // The DB still travels: a snapshot without the key is degraded, not // useless, and refusing here would block backups on a box whose key // path is overridden. expect(existsSync(join(stageRoot, 'celilo.db'))).toBe(true); }); it('stages the fleet keypair, private half included', () => { const sshDir = join(dataDir, '.ssh'); mkdirSync(sshDir, { recursive: true }); writeFileSync(join(sshDir, 'id_ed25519'), 'PRIVATE'); writeFileSync(join(sshDir, 'id_ed25519.pub'), 'ssh-ed25519 AAAA celilo-fleet'); const staged = stageSystemState(stageRoot); expect(staged.fleetSshStaged).toBe(true); // The DB carries only the public half. Without the private half on disk a // restored box cannot reach machines that already trust the key. expect(readFileSync(join(stageRoot, 'ssh', 'id_ed25519'), 'utf-8')).toBe('PRIVATE'); }); it('reports an absent fleet keypair rather than staging an empty dir', () => { const staged = stageSystemState(stageRoot); expect(staged.fleetSshStaged).toBe(false); expect(existsSync(join(stageRoot, 'ssh'))).toBe(false); }); it('captures LEAN module source: no generated/, no node_modules/, no oversized files', () => { const modSrc = join(dataDir, 'modules', 'caddy'); mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true }); mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true }); mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true }); writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy'); writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook'); writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}'); writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored'); // A >2MB "compiled binary" sitting in source — skipped by size, because // excluding by directory name misses the ones outside a known build dir. writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024)); const staged = stageSystemState(stageRoot); const at = (...parts: string[]) => join(stageRoot, 'module_src', 'caddy', ...parts); expect(staged.moduleSourceCount).toBe(1); expect(existsSync(at('manifest.yml'))).toBe(true); expect(existsSync(at('scripts', 'hook.ts'))).toBe(true); expect(existsSync(at('generated'))).toBe(false); expect(existsSync(at('scripts', 'node_modules'))).toBe(false); expect(existsSync(at('ansible', 'files', 'server-bin'))).toBe(false); }); it('names every file the size cap dropped', () => { const modSrc = join(dataDir, 'modules', 'caddy'); mkdirSync(join(modSrc, 'ansible'), { recursive: true }); writeFileSync(join(modSrc, 'ansible', 'server-bin'), Buffer.alloc(3 * 1024 * 1024)); const staged = stageSystemState(stageRoot); // No silent caps: a backup that quietly dropped a file reads as complete. expect(staged.skippedLarge).toHaveLength(1); expect(staged.skippedLarge[0]).toContain('caddy/ansible/server-bin'); expect(staged.skippedLarge[0]).toContain('3.0MB'); }); }); describe('snapshotDatabase', () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-db-snapshot-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it('captures rows still sitting in the WAL, uncheckpointed', () => { // The defect this guards: celilo runs the DB in WAL mode, so committed // rows live in celilo.db-wal until a checkpoint folds them into the main // file. A plain copy of the main file produces a snapshot that opens // cleanly and contains NOTHING, and restore then installs it. Swap // serialize() for copyFileSync and this test is the thing that notices. const src = join(dir, 'celilo.db'); const live = new Database(src); live.run('PRAGMA journal_mode = WAL'); live.run('CREATE TABLE probe (id INTEGER PRIMARY KEY, v TEXT)'); for (let i = 0; i < 200; i++) { live.run('INSERT INTO probe (v) VALUES (?)', [`row-${i}`]); } const dest = join(dir, 'snapshot.db'); snapshotDatabase(src, dest); live.close(); // Opened read-write, not readonly: the serialized bytes carry WAL journal // mode in their header, so the FIRST open has to be able to create the // -wal/-shm sidecars. Restore opens it read-write too (it copies the file // into place and runs migrations), so this is the real consumer's path. const restored = new Database(dest); const row = restored.query('SELECT COUNT(*) AS n FROM probe').get() as { n: number }; restored.close(); expect(row.n).toBe(200); }); });