/** * Top-level `celilo restore` command. * * Wraps the file-direct restore service for the fresh-bootstrap * ergonomics path: * * curl -fsSL https://celilo.computer/bootstrap.sh | bash * celilo restore --from [--force] * * Refuses to run when the target already holds celilo state (modules * in DB or terraform state on disk) unless --force is passed. * Calls restoreFromArtifactFile, then runs Drizzle migrations on the * restored DB so a backup from an older celilo still opens cleanly. * * Phase 4 of openspec/specs/management-server-backup/spec.md. */ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { migrateRestoredDb, restoreFromArtifactFile } from '../../services/restore-from-file'; import { NonEmptyRestoreTargetError, assertRestoreTargetEmpty, } from '../../services/restore-preflight'; import { getArg, hasFlag } from '../parser'; import { log } from '../prompts'; import type { CommandResult } from '../types'; export async function handleRestore( args: string[], flags: Record = {}, ): Promise { // --from is required; we don't infer it. const fromFlag = flags.from; const fromPositional = getArg(args, 0); const fromRaw = typeof fromFlag === 'string' ? fromFlag : fromPositional; if (!fromRaw || typeof fromRaw !== 'string') { return { success: false, error: [ 'celilo restore: missing --from ', '', 'Usage:', ' celilo restore --from [--force]', '', 'The artifact is a .backup file produced by `celilo module backup celilo-mgmt`.', ].join('\n'), }; } const filePath = resolve(fromRaw); if (!existsSync(filePath)) { return { success: false, error: `Artifact not found: ${filePath}`, }; } const force = hasFlag(flags, 'force'); // Pre-flight: refuse non-empty target unless --force. if (!force) { try { assertRestoreTargetEmpty(); } catch (err) { if (err instanceof NonEmptyRestoreTargetError) { return { success: false, error: err.message }; } throw err; } } else { log.warn('--force set; non-destructive pre-flight skipped.'); } log.info(`Restoring from ${filePath}...`); const result = await restoreFromArtifactFile(filePath, { force }); if (!result.success) { return { success: false, error: result.error ?? 'restore failed' }; } // Run Drizzle migrations on the restored DB so a backup from an // older celilo opens cleanly. No-op if the DB schema is already // current. A failure here FAILS the restore (celilo#1269): the swap // has landed, so the box holds restored state it cannot yet open // safely, and reporting success over an unmigrated database trains // operators to ignore the one warning that matters on a recovery // path. The error names the command that retries the migration and // the check that says whether the schema is complete. let migrationError: string | undefined; try { await migrateRestoredDb(); } catch (err) { migrationError = err instanceof Error ? err.message : String(err); } if (migrationError) { return { success: false, error: `Restore landed but migrations failed: ${migrationError}. The database was restored to this box, but its schema may not match this celilo build and celilo commands may fail against it. Run \`celilo system migrate\` to retry the migrations, and \`celilo system doctor\` to check whether the schema is complete before using the box.`, }; } // Re-register event-bus subscriptions from the restored modules' manifests. // The bus (events.db) is NOT in the backup envelope, so a restore/migration // starts with an EMPTY subscribers table — every event-driven reconcile (caddy // public_web, dns register, namecheap's 15m DDNS refresh, …) is dormant until // subscriptions are re-registered (ISS-0088). Reconstruct them from durable // celilo.db state rather than requiring a redeploy of every module. Best-effort: // the DB was just swapped + reopened, so on an unhappy reopen, point the // operator at the standalone command (which runs in a fresh process). let subsResynced: { modules: number; registered: number } | undefined; try { const { resyncAllSubscriptions } = await import('../../services/module-subscriptions'); const r = resyncAllSubscriptions(); subsResynced = { modules: r.modules, registered: r.registered }; if (r.failures.length > 0) { log.warn( `Re-registered ${r.registered} subscription(s); ${r.failures.length} module(s) failed — run \`celilo events resync-subscriptions\` to retry.`, ); } } catch (err) { log.warn( `Restore landed but event-bus subscriptions were not re-registered: ${err instanceof Error ? err.message : String(err)}. Event-driven reconciles stay dormant until you run \`celilo events resync-subscriptions\`.`, ); } // Build the success message. const lines: string[] = ['Restore complete.']; if (result.systemDbApplied) lines.push(' • celilo.db swapped into place'); if (subsResynced) { lines.push( ` • re-registered ${subsResynced.registered} event-bus subscription(s) from ${subsResynced.modules} deployed module(s)`, ); } if (result.masterKeyApplied) lines.push(' • master.key swapped into place'); if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to /.ssh/'); if (result.moduleSourcesApplied && result.moduleSourcesApplied > 0) { lines.push( ` • module source laid down for ${result.moduleSourcesApplied} module(s) (paths reconciled to this box)`, ); } if (result.crossModuleApplied && result.crossModuleApplied.length > 0) { lines.push( ` • terraform state restored for ${result.crossModuleApplied.length} module(s): ${result.crossModuleApplied.join(', ')}`, ); } lines.push(''); lines.push('Next steps:'); lines.push(' • Verify with `celilo module list` and `celilo status`.'); lines.push(" • Run `terraform plan` in each module's generated/ dir to confirm no drift."); // The restore swapped the DB file; the CLI's own connection was closed // and reopens fresh, but a long-running event-bus dispatcher (if you // installed one via `celilo events install-daemon`) still holds the old // DB. Tell the operator to bounce it — celilo doesn't manage that user // unit's lifecycle from here. lines.push( ' • If you run the event-bus daemon (`celilo events install-daemon`), restart it so it', ); lines.push( ' picks up the restored DB: systemctl --user restart celilo-events.service (Linux).', ); return { success: true, message: lines.join('\n'), }; }