/** * Pre-flight check for the celilo restore command. Refuses to run on a * target that already holds celilo state -- restoring onto a working * management server would clobber live state with no recovery path. * Operator passes --force to override. * * Two signals count as "non-empty": * 1. The celilo DB exists AND has rows in the modules table (i.e. * the operator has imported at least one module). * 2. Any //generated/terraform/ directory * exists (i.e. at least one module has been deployed). * * Either signal is sufficient. Both are checked so a half-deployed * target (DB cleared but disk artifacts left over) still gets caught. * * Phase 4 of openspec/specs/management-server-backup/spec.md (Design Decision 9). */ import { existsSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { getModuleStoragePath } from '../config/paths'; import { getDb } from '../db/client'; import { modules } from '../db/schema'; export interface PreflightReport { /** True if the target looks empty enough to restore onto. */ empty: boolean; /** Module IDs found in the DB (if any). Empty list if the DB doesn't exist. */ modulesInDb: string[]; /** Module IDs with a generated/terraform/ directory on disk. */ modulesWithTerraformState: string[]; } /** * Survey the target. Pure -- no mutations. Callers decide whether to * proceed (raw `empty` check) or to surface the findings to the * operator and require --force confirmation. */ export function surveyRestoreTarget(): PreflightReport { let modulesInDb: string[] = []; try { const db = getDb(); modulesInDb = db .select({ id: modules.id }) .from(modules) .all() .map((row) => row.id); } catch { // No DB / no migrations / file doesn't exist -- equivalent to "no // modules in DB" for pre-flight purposes. } const storageRoot = getModuleStoragePath(); const modulesWithTerraformState: string[] = []; if (existsSync(storageRoot)) { for (const entry of readdirSync(storageRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const tfDir = join(storageRoot, entry.name, 'generated', 'terraform'); if (existsSync(tfDir)) { try { // Treat empty terraform/ dirs as "no state" -- a leftover // dir without files isn't load-bearing. const tfStat = statSync(tfDir); if (tfStat.isDirectory() && readdirSync(tfDir).length > 0) { modulesWithTerraformState.push(entry.name); } } catch { /* tfDir vanished mid-survey; skip */ } } } } return { empty: modulesInDb.length === 0 && modulesWithTerraformState.length === 0, modulesInDb, modulesWithTerraformState, }; } /** * Thrown by assertRestoreTargetEmpty() when the survey finds state. * Carries the full report so the CLI handler can format an actionable * message without re-surveying. */ export class NonEmptyRestoreTargetError extends Error { constructor(public readonly report: PreflightReport) { const lines: string[] = ['Refusing to restore: target is not empty.']; if (report.modulesInDb.length > 0) { lines.push( ` ${report.modulesInDb.length} module(s) registered in DB: ${report.modulesInDb.join(', ')}`, ); } if (report.modulesWithTerraformState.length > 0) { lines.push( ` ${report.modulesWithTerraformState.length} module(s) with deployed terraform state: ${report.modulesWithTerraformState.join(', ')}`, ); } lines.push(''); lines.push( 'Restoring would clobber the existing state. Pass --force to proceed (irreversible).', ); super(lines.join('\n')); this.name = 'NonEmptyRestoreTargetError'; } } /** * Throw NonEmptyRestoreTargetError when the target has state. Caller * (the celilo restore CLI handler) catches and exits with the error * message + non-zero status when --force isn't set. */ export function assertRestoreTargetEmpty(): void { const report = surveyRestoreTarget(); if (!report.empty) { throw new NonEmptyRestoreTargetError(report); } }