/** * Backup creation service. * Orchestrates the backup workflow: create temp files, encrypt, upload to storage. */ import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { create as tarCreate } from 'tar'; import { getDbPath, getMasterKeyPath } from '../config/paths'; import { getDb } from '../db/client'; import { moduleConfigs, modules, secrets as secretsTable } 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 { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { encryptFileToFile } from './backup-cipher'; import { buildManifest } from './backup-manifest'; import { completeBackup, createBackupRecord, failBackup, loadBackupHistory, } from './backup-metadata'; import { isBackupDueFromHistory } from './backup-schedule'; import { stagingDirFor } from './backup-staging'; import { createStorageProvider, getBackupStorage, getBackupStorageByStorageId, getDefaultBackupStorage, } from './backup-storage'; import type { Cadence } from './cadence'; import { materializeCrossModuleRoot, moduleHasCrossModuleRead } from './cross-module-read'; import { getModuleSystems } from './deployed-systems'; import { listMachines } from './machine-pool'; import { parseStoredConfigValue } from './module-config'; import { completeOperation, failOperation, refuseIfInFlight, startOperation, } from './module-operations'; import { remoteAccessPolicy } from './remote-access'; import { stageSystemState } from './system-state-stage'; export interface BackupCreateOptions { storageId?: string; force?: boolean; } export interface BackupCreateResult { success: boolean; backupId?: string; storagePath?: string; sizeBytes?: number; schemaVersion?: string; error?: string; } /** * Resolve the target storage destination */ export function resolveStorage(storageId?: string) { if (storageId) { // Accept the human storageId NAME (what `storage list` shows and what the // operator passes to --storage, e.g. "aws-backups") first, falling back to // the internal UUID. Users never see UUIDs (CLAUDE.md), so name-first is // the correct resolution order. const storage = getBackupStorageByStorageId(storageId) ?? getBackupStorage(storageId); if (!storage) { throw new Error(`Storage not found: ${storageId}`); } if (!storage.verified) { throw new Error( `Storage '${storage.storageId}' is not verified. Run: celilo storage verify ${storage.storageId}`, ); } return storage; } const defaultStorage = getDefaultBackupStorage(); if (!defaultStorage) { throw new Error( 'No default backup storage configured.\n\nAdd storage first: celilo storage add local', ); } if (!defaultStorage.verified) { throw new Error( `Default storage '${defaultStorage.storageId}' is not verified. Run: celilo storage verify ${defaultStorage.storageId}`, ); } return defaultStorage; } /** * Create a system state backup (Celilo database) */ export async function createSystemStateBackup( options: BackupCreateOptions = {}, ): Promise { // Refuse if any module operation is in flight (deploy/uninstall/backup/ // restore). Surfaces as InFlightError to the caller with a list of // conflicting operations. refuseIfInFlight(); const storage = resolveStorage(options.storageId); const provider = await createStorageProvider(storage.id); const now = new Date(); const dateDir = now.toISOString().slice(0, 10); const timestamp = now.toISOString().replace(/[:.]/g, '-'); const storagePath = `${dateDir}/system-${timestamp}.backup`; const record = createBackupRecord({ moduleId: null, storageId: storage.id, storagePath, backupType: 'system_state', }); const opId = startOperation('__system__', 'backup'); const tempDir = stagingDirFor(record.id); try { mkdirSync(tempDir, { recursive: true }); // Lay out the envelope contents in a staging dir, then tar + encrypt // the whole thing. Envelope layout (v1.0): // manifest.json - schema version, host, kind, timestamp // celilo.db - SQLite snapshot // celilo.db-wal - WAL file (if present at backup time) const envelopeDir = join(tempDir, 'envelope'); mkdirSync(envelopeDir, { recursive: true }); // Copy the SQLite database to envelope (safe point-in-time copy) const dbPath = getDbPath(); copyFileSync(dbPath, join(envelopeDir, 'celilo.db')); // Also copy WAL file if it exists (for consistency) const walPath = `${dbPath}-wal`; try { copyFileSync(walPath, join(envelopeDir, 'celilo.db-wal')); } catch { // WAL may not exist — that's fine } // Write the manifest into the envelope. Restore reads this first to // validate envelope-schema compatibility before doing anything else. const manifest = buildManifest({ kind: 'system' }); writeFileSync(join(envelopeDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); // Tar the envelope. Operator can re-extract a backup file manually // for diagnostics: `age -d -p file.backup | tar -t` shows the contents. const tarPath = join(tempDir, 'envelope.tar'); await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']); // Encrypt the tar, streamed — see backup-cipher.ts. The plaintext is // never held in memory, so a large fleet DB can't OOM the snapshot. const masterKey = await getOrCreateMasterKey(); const encryptedPath = join(tempDir, 'system.enc'); await encryptFileToFile(tarPath, encryptedPath, masterKey); const tarSize = statSync(tarPath).size; const encryptedSize = statSync(encryptedPath).size; // Upload to storage await provider.upload(encryptedPath, storagePath); // Mark backup as completed completeBackup(record.id, { sizeBytes: encryptedSize, metadata: { originalSizeBytes: tarSize, dbPath, envelopeSchemaVersion: manifest.schemaVersion, }, }); completeOperation(opId); return { success: true, backupId: record.id, storagePath, sizeBytes: encryptedSize, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); failBackup(record.id, message); failOperation(opId, error); return { success: false, backupId: record.id, error: message, }; } finally { // Clean up temp directory try { rmSync(tempDir, { recursive: true, force: true }); } catch { // Best effort cleanup } } } /** * Check if a module is due for backup based on its schedule. * * The policy lives in `isBackupDueFromHistory`; this reads the history it * needs. Worth knowing what the previous version did, because its shape was * the bug: it asked for `limit: 1` — the single most recent row, whatever its * outcome — and then `.find()`-ed a *completed* one within that. Those two * lines contradict each other. Once an attempt failed, the newest row was that * failure, the find matched nothing, and "no successful backup exists" meant * unconditionally due — at every hourly tick, forever, however the module's * cadence read (celilo#685). */ export function isBackupDue(moduleId: string, schedule: Cadence): boolean { return isBackupDueFromHistory(schedule, loadBackupHistory(moduleId), Date.now()); } /** * Find all installed modules that have an on_backup hook. * * Each carries its operator config alongside its manifest, because every * caller then has to resolve a cadence or a retention policy out of the two * together — and a caller that got the manifest alone would silently ignore * the operator's override. */ export function findBackupEligibleModules(): Array<{ module: typeof modules.$inferSelect; manifest: ModuleManifest; configs: Record; }> { const db = getDb(); const allModules = db.select().from(modules).all(); const configsByModule = new Map>(); for (const row of db.select().from(moduleConfigs).all()) { const forModule = configsByModule.get(row.moduleId) ?? {}; forModule[row.key] = parseStoredConfigValue(row); configsByModule.set(row.moduleId, forModule); } const eligible: Array<{ module: typeof modules.$inferSelect; manifest: ModuleManifest; configs: Record; }> = []; for (const mod of allModules) { if (mod.state !== 'INSTALLED' && mod.state !== 'VERIFIED') continue; const manifest = mod.manifestData as unknown as ModuleManifest; if (!manifest.hooks?.on_backup) continue; eligible.push({ module: mod, manifest, configs: configsByModule.get(mod.id) ?? {} }); } return eligible; } /** * Build config and secret maps for a module (same pattern as health-runner.ts) */ async function buildModuleContext(moduleId: string): Promise<{ configMap: Record; secretMap: Record; }> { const db = getDb(); const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all(); const configMap: Record = {}; for (const c of configs) { configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value; } const secretRecords = db .select() .from(secretsTable) .where(eq(secretsTable.moduleId, moduleId)) .all(); const masterKey = await getOrCreateMasterKey(); const secretMap: Record = {}; for (const s of secretRecords) { secretMap[s.name] = decryptSecret( { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag }, masterKey, ); } return { configMap, secretMap }; } /** * Create a module data backup by executing its on_backup hook */ export async function createModuleBackup( moduleId: string, options: BackupCreateOptions = {}, ): Promise { const db = getDb(); const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!mod) { return { success: false, error: `Module not found: ${moduleId}` }; } const manifest = mod.manifestData as unknown as ModuleManifest; const hookDef = manifest.hooks?.on_backup; if (!hookDef) { return { success: false, error: `Module '${moduleId}' has no on_backup hook` }; } // Refuse if any module operation is in flight (deploy/uninstall/backup/ // restore). Checked after we've validated the module exists + has a // hook, so the operator gets the more useful error first. refuseIfInFlight(); const storage = resolveStorage(options.storageId); const provider = await createStorageProvider(storage.id); const now = new Date(); const dateDir = now.toISOString().slice(0, 10); const timestamp = now.toISOString().replace(/[:.]/g, '-'); const storagePath = `${dateDir}/${moduleId}-${timestamp}.backup`; const record = createBackupRecord({ moduleId, storageId: storage.id, storagePath, backupType: 'module_data', moduleVersion: manifest.version, }); const opId = startOperation(moduleId, 'backup'); // Envelope layout (v1.0): // manifest.json - schema version, host, moduleId, dataSchemaVersion // data/ - on_backup hook artifacts (the hook treats this as backup_dir) const tempDir = stagingDirFor(record.id); const envelopeDir = join(tempDir, 'envelope'); const dataDir = join(envelopeDir, 'data'); try { mkdirSync(dataDir, { recursive: true }); // Build context for hook execution const { configMap, secretMap } = await buildModuleContext(moduleId); const logger = createConsoleLogger(moduleId, 'on_backup'); const hookInputs: Record = { backup_dir: dataDir, }; // Both privileged inputs ride the same allow-list. `cross_module_root` // mirrors OTHER modules' terraform state; `system_state_root` stages // celilo's own — the DB snapshot, master.key, the fleet key, and every // module's lean source. // // Staging is what lets celilo back ITSELF up without exempting // celilo-mgmt from the hook jail (design D9b). The hook used to receive // `db_path` and walk out from `dirname(db_path)` into celilo's data // directory; it never read those bytes, it copied them, so the framework // does the copying and the data directory stays out of the mount set. if (moduleHasCrossModuleRead(manifest)) { const crossModuleRoot = join(tempDir, 'cross-module-read'); materializeCrossModuleRoot(crossModuleRoot, moduleId); hookInputs.cross_module_root = crossModuleRoot; const staged = stageSystemState(join(tempDir, 'system-state')); hookInputs.system_state_root = staged.root; // The machine inventory rides the same privilege. on_backup used to // shell `celilo machine list --json` for this, which the hook jail kills // (no celilo binary, celilo#1225) and which never worked before that: // the CLI had no --json flag, so the hook's JSON.parse failed and its // catch wrote [] into every envelope. listMachines() is the same read // the CLI performs, offered as staged data instead of a spawn. hookInputs.machine_pool = JSON.stringify(await listMachines()); if (!staged.masterKeyStaged) { logger.warn( `master.key not found at ${getMasterKeyPath()} — secrets in the DB snapshot will be unreadable on restore.`, ); } if (!staged.fleetSshStaged) { logger.info( 'No fleet SSH keypair on this box — none staged (celilo-mgmt may not have been deployed yet).', ); } logger.info(`Staged celilo state: ${staged.moduleSourceCount} module source tree(s)`); if (staged.skippedLarge.length > 0) { // No silent caps: name what was dropped. These are build artifacts the // target rebuilds on deploy. logger.info( `Skipped ${staged.skippedLarge.length} large/non-source file(s) (rebuilt on deploy): ${staged.skippedLarge.join(', ')}`, ); } } // Execute on_backup hook — it writes artifacts to dataDir (envelope/data/) const hookResult = await invokeHook( mod.sourcePath, 'on_backup', manifest.celilo_contract, hookDef, hookInputs, configMap, secretMap, logger, { debug: false, systems: getModuleSystems(moduleId, db), remoteAccess: remoteAccessPolicy(moduleId, db), hookStores: () => createHookStores(db, moduleId), }, ); if (!hookResult.success) { const errMsg = hookResult.error ?? 'on_backup hook failed'; failBackup(record.id, errMsg); failOperation(opId, errMsg); return { success: false, backupId: record.id, error: errMsg, }; } // Extract schema_version from hook outputs (optional) — the module's // own data schema version, which restore threads back to on_restore // so the hook can migrate older data shapes if needed. const dataSchemaVersion = typeof hookResult.outputs.schema_version === 'string' ? hookResult.outputs.schema_version : undefined; // Write the manifest into the envelope alongside data/. const backupManifest = buildManifest({ kind: 'module', moduleId, moduleVersion: manifest.version, dataSchemaVersion, }); writeFileSync(join(envelopeDir, 'manifest.json'), JSON.stringify(backupManifest, null, 2)); // Tar the envelope (manifest.json + data/). const tarPath = join(tempDir, 'envelope.tar'); await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']); // Encrypt the tar, streamed — see backup-cipher.ts. Module artifacts run // to hundreds of MB (forgejo's are ~774 MB); holding one in memory is // what OOM-killed that backup. const masterKey = await getOrCreateMasterKey(); const encryptedPath = join(tempDir, 'backup.tar.enc'); await encryptFileToFile(tarPath, encryptedPath, masterKey); const tarSize = statSync(tarPath).size; const encryptedSize = statSync(encryptedPath).size; // Upload to storage await provider.upload(encryptedPath, storagePath); // Mark backup as completed completeBackup(record.id, { sizeBytes: encryptedSize, metadata: { artifactCount: hookResult.outputs.artifact_count, originalSizeBytes: tarSize, envelopeSchemaVersion: backupManifest.schemaVersion, }, schemaVersion: dataSchemaVersion, }); completeOperation(opId); return { success: true, backupId: record.id, storagePath, sizeBytes: encryptedSize, schemaVersion: dataSchemaVersion, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); failBackup(record.id, message); failOperation(opId, error); return { success: false, backupId: record.id, error: message, }; } finally { try { rmSync(tempDir, { recursive: true, force: true }); } catch { // Best effort cleanup } } } export interface ImportBackupOptions { storageId?: string; schemaVersion?: string; name?: string; } /** * Import a local file as a module backup (bypasses on_backup hook). * The file is placed into the backup archive as `db.sqlite`, matching * the artifact name produced by the on_backup hook. */ export async function importModuleBackup( filePath: string, moduleId: string, options: ImportBackupOptions = {}, ): Promise { const db = getDb(); const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!mod) { return { success: false, error: `Module not found: ${moduleId}` }; } if (!existsSync(filePath)) { return { success: false, error: `File not found: ${filePath}` }; } const manifest = mod.manifestData as unknown as ModuleManifest; const storage = resolveStorage(options.storageId); const provider = await createStorageProvider(storage.id); const now = new Date(); const dateDir = now.toISOString().slice(0, 10); const timestamp = now.toISOString().replace(/[:.]/g, '-'); const storagePath = `${dateDir}/${moduleId}-${timestamp}.backup`; const record = createBackupRecord({ moduleId, storageId: storage.id, storagePath, backupType: 'module_data', moduleVersion: manifest.version, }); const tempDir = stagingDirFor(record.id); const artifactDir = join(tempDir, 'artifacts'); try { mkdirSync(artifactDir, { recursive: true }); // Copy the file as db.sqlite (matching on_backup hook output) const destPath = join(artifactDir, 'db.sqlite'); copyFileSync(filePath, destPath); // If the module has an on_backup_analyze hook, invoke it to extract metadata let analyzedSchemaVersion: string | undefined = options.schemaVersion; let analyzedMetadata: Record = { artifactCount: '1', originalSizeBytes: 0, importedFrom: filePath, }; const analyzeHook = manifest.hooks?.on_backup_analyze; if (analyzeHook) { const { configMap, secretMap } = await buildModuleContext(moduleId); const logger = createConsoleLogger(moduleId, 'on_backup_analyze'); const analyzeResult = await invokeHook( mod.sourcePath, 'on_backup_analyze', manifest.celilo_contract, analyzeHook, { artifact_path: destPath }, configMap, secretMap, logger, { debug: false, systems: getModuleSystems(moduleId, db), remoteAccess: remoteAccessPolicy(moduleId, db), hookStores: () => createHookStores(db, moduleId), }, ); if (analyzeResult.success) { // Extract schema_version from hook outputs if available if (typeof analyzeResult.outputs.schema_version === 'string') { analyzedSchemaVersion = analyzeResult.outputs.schema_version; } // Merge all hook outputs into metadata analyzedMetadata = { ...analyzedMetadata, ...analyzeResult.outputs, importedFrom: filePath, }; } else { // Hook failed - log warning but continue with import console.warn( `Warning: on_backup_analyze hook failed: ${analyzeResult.error}. Continuing with manual metadata.`, ); } } // Tar the artifacts const tarPath = join(tempDir, 'backup.tar'); await tarCreate({ file: tarPath, cwd: artifactDir }, ['.']); // Encrypt the tar, streamed — see backup-cipher.ts. const masterKey = await getOrCreateMasterKey(); const encryptedPath = join(tempDir, 'backup.tar.enc'); await encryptFileToFile(tarPath, encryptedPath, masterKey); const tarSize = statSync(tarPath).size; const encryptedSize = statSync(encryptedPath).size; // Upload to storage await provider.upload(encryptedPath, storagePath); // Mark backup as completed completeBackup(record.id, { sizeBytes: encryptedSize, metadata: { ...analyzedMetadata, originalSizeBytes: tarSize, }, schemaVersion: analyzedSchemaVersion, }); // Set human-readable name if provided if (options.name) { const { updateBackupName } = await import('./backup-metadata'); updateBackupName(record.id, options.name); } return { success: true, backupId: record.id, storagePath, sizeBytes: encryptedSize, schemaVersion: analyzedSchemaVersion, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); failBackup(record.id, message); return { success: false, backupId: record.id, error: message, }; } finally { try { rmSync(tempDir, { recursive: true, force: true }); } catch { // Best effort cleanup } } }