/** * File-direct restore + system-file swap. Powers the `celilo restore` * top-level convenience command (Phase 4 of openspec/specs/management-server-backup/spec.md). * * The generic `restoreModuleBackup` reads from a configured storage * destination — which on a fresh-bootstrap box doesn't exist yet. * `restoreFromArtifactFile` takes a local file path directly: decrypts * + extracts + validates the envelope, then invokes the module's * on_restore hook through the same plumbing (cross_module_write_root * apply, in-flight refusal, operation tracking). * * After the hook returns, `applyStagedSystemFiles` picks up the * celilo.db + master.key that celilo-mgmt's on_restore staged under * restore_dir/system/, closes the live DB, copies them into place, * and runs Drizzle migrations on the restored DB. This is the chunk * of the restore the hook itself cannot do (live DB is open). */ import { Database } from 'bun:sqlite'; import { chmodSync, closeSync, copyFileSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { extract as tarExtract } from 'tar'; import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { modules } from '../db/schema'; import { invokeHook } from '../hooks/executor'; import { createHookStores } from '../hooks/hook-store'; import { createConsoleLogger } from '../hooks/logger'; import type { ModuleManifest } from '../manifest/schema'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { decryptFileToFile } from './backup-cipher'; import { assertCompatibleSchema, parseManifest } from './backup-manifest'; import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read'; import { getModuleSystems } from './deployed-systems'; import { getFleetSshDir } from './fleet-key'; import { completeOperation, failOperation, refuseIfInFlight, startOperation, } from './module-operations'; import { remoteAccessPolicy } from './remote-access'; export interface RestoreFromFileResult { success: boolean; error?: string; /** Module IDs whose cross-module TF state was applied. */ crossModuleApplied?: string[]; /** Was celilo.db swapped into place? */ systemDbApplied?: boolean; /** Was master.key swapped into place? */ masterKeyApplied?: boolean; /** Was the fleet SSH keypair restored to /.ssh/? */ sshKeyApplied?: boolean; /** How many module source dirs were laid down at the target's modules dir. */ moduleSourcesApplied?: number; } export interface RestoreFromFileOptions { /** Allow restoring onto a populated target. Default false (refuse). */ force?: boolean; } /** * Decrypt + extract a backup artifact file, invoke the module's * on_restore hook, apply cross-module write-back, and apply staged * system files (celilo.db + master.key). Returns a result describing * what landed. * * The module identified by the artifact's manifest.json MUST already * exist in the DB. The `celilo restore` CLI handler (Phase 4) ensures * this — on truly fresh bootstrap, bootstrap.sh imports celilo-mgmt * from the registry before invoking restore. */ export async function restoreFromArtifactFile( filePath: string, _options: RestoreFromFileOptions = {}, ): Promise { // refuseIfInFlight() before anything else — a restore alongside an // active deploy would race with whichever process holds DB locks. refuseIfInFlight(); if (!existsSync(filePath)) { return { success: false, error: `Artifact not found: ${filePath}` }; } // 1. Decrypt the artifact into the inner tar (see backup-cipher.ts — // handles both the streamed format and the legacy JSON envelope). let masterKey: Buffer; try { masterKey = await getOrCreateMasterKey(); } catch (err) { return { success: false, error: `Cannot read master key: ${err instanceof Error ? err.message : String(err)}. The current master key must match the one the artifact was encrypted with.`, }; } const tempDir = join(tmpdir(), `celilo-restore-from-file-${Date.now()}`); const envelopeDir = join(tempDir, 'envelope'); try { mkdirSync(envelopeDir, { recursive: true }); const tarPath = join(tempDir, 'envelope.tar'); try { await decryptFileToFile(filePath, tarPath, masterKey); } catch (err) { return { success: false, error: `Decryption failed: ${err instanceof Error ? err.message : String(err)}. Either the master key does not match the one used at backup time, or the file is corrupted / not a celilo backup artifact.`, }; } // Extract the envelope tar. await tarExtract({ file: tarPath, cwd: envelopeDir }); // 2. Read + validate the envelope manifest. const manifestPath = join(envelopeDir, 'manifest.json'); if (!existsSync(manifestPath)) { return { success: false, error: 'Artifact has no manifest.json. It may be from an older celilo (pre-envelope format) or corrupted.', }; } const envelopeManifest = parseManifest(readFileSync(manifestPath, 'utf-8')); assertCompatibleSchema(envelopeManifest); if (envelopeManifest.kind !== 'module') { return { success: false, error: `Artifact kind is '${envelopeManifest.kind}', expected 'module'. (System artifacts shouldn't reach restore-from-file; use 'celilo backup restore' for those.)`, }; } const moduleId = envelopeManifest.moduleId; if (!moduleId) { return { success: false, error: "Artifact manifest doesn't name a moduleId. The artifact may be corrupted.", }; } const restoreDataDir = join(envelopeDir, 'data'); if (!existsSync(restoreDataDir)) { return { success: false, error: 'Artifact has no data/ directory.' }; } // 3. Look up the module's on_restore hook. The DB must already // have the module — bootstrap.sh's job is to import celilo-mgmt // before reaching restore. const db = getDb(); const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!mod) { return { success: false, error: `Module '${moduleId}' is not imported. Run 'celilo module import ${moduleId}' first (or use the bootstrap.sh script which handles this for you).`, }; } const manifest = mod.manifestData as unknown as ModuleManifest; const hookDef = manifest.hooks?.on_restore; if (!hookDef) { return { success: false, error: `Module '${moduleId}' has no on_restore hook defined.`, }; } // 4. Set up cross-module write-back staging if the privilege is // granted. Same pattern as restoreModuleBackup. const opId = startOperation(moduleId, 'restore'); const hookInputs: Record = { restore_dir: restoreDataDir, schema_version: envelopeManifest.dataSchemaVersion ?? '', }; let crossModuleWriteRoot: string | undefined; if (moduleHasCrossModuleRead(manifest)) { crossModuleWriteRoot = join(tempDir, 'cross-module-write'); mkdirSync(crossModuleWriteRoot, { recursive: true }); hookInputs.cross_module_write_root = crossModuleWriteRoot; } // 5. Invoke the hook. Resolve the module path from getModuleStoragePath() // rather than the stored mod.sourcePath (ISS-0052): on a box whose DB was // produced by a prior restore from ANOTHER box, source_path is that box's // absolute path (e.g. a macOS /Users/... path on a Linux box), and the // hook executor's screenshot-dir mkdir then EACCES'es on it. By // construction (import.ts) the code always lives at // ${getModuleStoragePath()}/, so resolve there. Falls back to the // stored path only if the canonical dir is absent (e.g. a custom layout). const canonicalModulePath = join(getModuleStoragePath(), mod.id); const onRestoreModulePath = existsSync(canonicalModulePath) ? canonicalModulePath : mod.sourcePath; const logger = createConsoleLogger(mod.id, 'on_restore'); const hookResult = await invokeHook( onRestoreModulePath, 'on_restore', manifest.celilo_contract, hookDef, hookInputs, {}, {}, logger, { debug: false, systems: getModuleSystems(mod.id, db), remoteAccess: remoteAccessPolicy(mod.id, db), hookStores: () => createHookStores(db, mod.id), }, ); if (!hookResult.success) { const errMsg = hookResult.error ?? 'on_restore hook failed'; failOperation(opId, errMsg); return { success: false, error: errMsg }; } // 6. Apply cross-module write-back (atomic rotate-into-place). let crossModuleApplied: string[] = []; if (crossModuleWriteRoot) { try { const applyResult = applyCrossModuleWriteRoot(crossModuleWriteRoot); crossModuleApplied = applyResult.applied; } catch (err) { const errMsg = `cross_module_write_root apply failed: ${err instanceof Error ? err.message : String(err)}`; failOperation(opId, errMsg); return { success: false, error: errMsg }; } } // 7. Complete the operation BEFORE swapping the DB. The swap // (applyStagedSystemFiles) is the final, irreversible step: it closes // the live DB and overwrites celilo.db with the artifact's. Marking the // operation complete writes to module_operations — if done AFTER the // swap it reopens the freshly-staged artifact DB in THIS process and // throws SQLITE_IOERR (a fresh process opens the same file fine; it's a // same-process post-swap reopen artifact). The operation row lives in // the pre-swap DB the swap discards anyway, so completing it here — // while that DB is still open — is correct and avoids the reopen. // Nothing in-process must touch the DB after the swap; migrateRestoredDb // (the CLI's next step) is best-effort and tolerates a reopen failure. completeOperation(opId); // 8. Apply staged system files (celilo.db + master.key + fleet SSH key). // Staged by celilo-mgmt's on_restore at restore_dir/system/. The live // process had the DB open, so this swap happens AFTER the hook returns. const systemStaging = join(restoreDataDir, 'system'); const apply = applyStagedSystemFiles(systemStaging); return { success: true, crossModuleApplied, systemDbApplied: apply.dbApplied, masterKeyApplied: apply.keyApplied, sshKeyApplied: apply.sshApplied, moduleSourcesApplied: apply.moduleSourcesApplied, }; } finally { try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } } export interface StagedSystemApplyResult { dbApplied: boolean; keyApplied: boolean; sshApplied: boolean; /** How many module source dirs were laid down at the target's modules dir. */ moduleSourcesApplied: number; } /** Cheap check for the SQLite file magic without reading the whole DB. */ function looksLikeSqlite(path: string): boolean { let fd: number; try { fd = openSync(path, 'r'); } catch { return false; } try { const buf = Buffer.alloc(16); readSync(fd, buf, 0, 16, 0); // Magic header is "SQLite format 3" (15 bytes) + a NUL terminator. return buf.subarray(0, 15).toString('utf-8') === 'SQLite format 3' && buf[15] === 0; } finally { closeSync(fd); } } /** * Recompute every module's source_path to THIS box's modules dir, on the * STAGED DB file (pre-swap). The artifact carries the SOURCE box's absolute * source_path (e.g. macOS `~/Library/Application Support/celilo/modules/`), * which doesn't exist on a different box/OS — so a restored DB references module * code at a dead path and no deploy can generate. By construction (import.ts) * source_path is always `${dataDir}/modules/`, so it's safe to recompute for * the target; a no-op when the paths already match (same-OS restore). * * Done on the staged file (a fresh connection that's about to be swapped in), * never the live DB, so there's no post-swap in-process reopen (ISS-0037). The * PRAGMA forces commits into the main file (no -wal sibling) so the copy below * captures the rewrite; the live DB re-enters WAL when celilo reopens it. */ function rewriteStagedModuleSourcePaths(stagedDbPath: string): void { // Tolerate non-SQLite staged files: some callers/tests stage placeholder // bytes to exercise the file-copy path. A real artifact DB always opens // with the "SQLite format 3\0" magic header — skip anything that doesn't // (a valid-header-but-corrupt DB still surfaces by throwing below). if (!looksLikeSqlite(stagedDbPath)) return; const moduleStorageBase = getModuleStoragePath(); const staged = new Database(stagedDbPath); try { staged.query('PRAGMA journal_mode = DELETE').run(); const rows = staged.query('SELECT id FROM modules').all() as Array<{ id: string }>; const update = staged.query('UPDATE modules SET source_path = ? WHERE id = ?'); for (const { id } of rows) { update.run(join(moduleStorageBase, id), id); } } finally { staged.close(); } } /** * Picks up the celilo.db + master.key that celilo-mgmt's on_restore * staged under /system/, closes the live DB, and copies * each into its live path. Then runs Drizzle migrations on the * restored DB so a snapshot from an older celilo can still be opened. * * Skips files that aren't present — restoring just a master.key (no * DB) is a valid use case (e.g., recovering from a lost-key scenario * without a full system restore). * * Exported for direct use by callers that don't go through * restoreFromArtifactFile (tests, future system-only restore paths). */ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemApplyResult { let dbApplied = false; let keyApplied = false; let sshApplied = false; let moduleSourcesApplied = 0; if (!existsSync(systemStagingDir)) { return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied }; } const stagedDb = join(systemStagingDir, 'celilo.db'); const stagedKey = join(systemStagingDir, 'master.key'); const stagedSsh = join(systemStagingDir, 'ssh'); const stagedModuleSrc = join(systemStagingDir, 'module_src'); // master.key first: it's not load-bearing for the running process // (already in memory if the daemon's running). Safe to swap before // closing the DB. if (existsSync(stagedKey)) { const livePath = getMasterKeyPath(); mkdirSync(join(livePath, '..'), { recursive: true }); copyFileSync(stagedKey, livePath); keyApplied = true; } // Fleet SSH keypair → /.ssh/, restoring strict perms (the dir // 0700, private key 0600, public 0644). celilo uses this key to reach // managed machines; the DB only carries the public half, so without the // private half on disk the restored box can't authenticate to the fleet. if (existsSync(stagedSsh)) { // One helper, shared with the minting side, so restore and mint cannot // drift to different directories. See getFleetSshDir for why it follows // the DB rather than getDataDir(). const liveSshDir = getFleetSshDir(); mkdirSync(liveSshDir, { recursive: true, mode: 0o700 }); chmodSync(liveSshDir, 0o700); for (const entry of readdirSync(stagedSsh)) { const dest = join(liveSshDir, entry); copyFileSync(join(stagedSsh, entry), dest); chmodSync(dest, entry.endsWith('.pub') ? 0o644 : 0o600); } sshApplied = true; } // Module SOURCE code → lay down at THIS box's modules dir so the restored // box has the code for EVERY module (incl. non-registry ones like lunacycle). // on_backup captured each module's source; without this the restored DB // references modules whose code isn't on disk and no deploy can generate. // The artifact carries no generated/ subtree, so any existing generated/ // under a module dir (e.g. restored TF state) is preserved by the merge. if (existsSync(stagedModuleSrc)) { const moduleStorageBase = getModuleStoragePath(); mkdirSync(moduleStorageBase, { recursive: true }); for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) { if (!entry.isDirectory()) continue; // `force` explicit: this merges over an EXISTING module dir and the // restored bytes must win. bun does not honour the documented default // on every cp path (see generator.ts#copyAnsibleRoleFilesDirs). cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), { recursive: true, force: true, }); moduleSourcesApplied += 1; } } // celilo.db: must close the live DB connection before replacing // the file or SQLite gets confused (macOS holds a vnode handle). if (existsSync(stagedDb)) { // Reconcile module source_paths to THIS box on the staged file BEFORE the // swap (and before closeDb) — fixes cross-host / cross-OS restores. See // rewriteStagedModuleSourcePaths. rewriteStagedModuleSourcePaths(stagedDb); closeDb(); const livePath = getDbPath(); mkdirSync(join(livePath, '..'), { recursive: true }); // Swap by RENAME, not by in-place copy. copyFileSync overwrites the live // inode in place, so any connection whose fd close was deferred (measured // 2026-09-06, celilo#1293 live repro: bun:sqlite's close() returns before // the fds are released) keeps its WAL shared locks on the NEW content, and // the next opener's DELETE→WAL conversion in ensureWalMode then fails with // "database is locked" for its whole retry budget. A rename gives the new // file a fresh inode nobody has ever held. It also makes the swap atomic: // a crash mid-copy left a truncated live DB; a crash mid-rename leaves the // old one. const stagedSwapPath = `${livePath}.restore-swap`; copyFileSync(stagedDb, stagedSwapPath); renameSync(stagedSwapPath, livePath); // Stale WAL/SHM siblings belong to the replaced inode — remove them before // any new connection opens this path, or SQLite would try to recover // through a foreign WAL. for (const suffix of ['-wal', '-shm']) { try { rmSync(`${livePath}${suffix}`, { force: true }); } catch { /* may not exist */ } } dbApplied = true; } return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied }; } /** * Migrate the restored DB to the current celilo build's schema. Called * by the CLI handler after applyStagedSystemFiles so a backup from an * older celilo still opens cleanly. Pure I/O wrapper around * `runMigrations` exposed here so the same orchestration lives next * to the swap logic. */ export async function migrateRestoredDb(): Promise { const dbPath = getDbPath(); if (!existsSync(dbPath)) return; await runMigrations(dbPath); }