/** * Snapshot-based rollback for the OpenPalm control plane. * * Before writing validated changes to live paths, the current state * is snapshotted to OP_HOME/data/rollback/. On deploy failure * (or manual `openpalm rollback`), the snapshot is restored. */ import { mkdirSync, copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import type { ControlPlaneState } from "./types.js"; import { resolveRollbackDir } from "./home.js"; /** Files that are tracked for rollback (relative to homeDir). * Only config/ system files are included — user-editable config files * are never overwritten by lifecycle operations. */ const SNAPSHOT_FILES = [ "knowledge/env/stack.env", "system/stack/services.compose.yml", "system/stack/portals.compose.yml", "config/stack/custom.compose.yml", "knowledge/secrets/auth.json", ]; const SNAPSHOT_TS_FILE = '.snapshot-ts'; const SNAPSHOT_ARMED_FILE = '.snapshot-armed'; /** * Copy a file if it exists, creating parent directories as needed. */ function safeCopy(src: string, dest: string): void { if (!existsSync(src)) return; mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); } /** * Save the current live configuration files to the rollback directory. * Also snapshots stack/core.compose.yml. */ export function snapshotCurrentState(state: ControlPlaneState, opts: { arm?: boolean } = {}): void { const rollbackDir = resolveRollbackDir(); mkdirSync(rollbackDir, { recursive: true }); // Snapshot known files for (const rel of SNAPSHOT_FILES) { const src = join(state.homeDir, rel); const dest = join(rollbackDir, rel); safeCopy(src, dest); } // Snapshot system/stack/core.compose.yml const coreCompose = join(state.homeDir, "system/stack/core.compose.yml"); safeCopy(coreCompose, join(rollbackDir, "system/stack/core.compose.yml")); // Write a timestamp marker writeFileSync( join(rollbackDir, SNAPSHOT_TS_FILE), new Date().toISOString() + "\n", ); if (opts.arm) { writeFileSync(join(rollbackDir, SNAPSHOT_ARMED_FILE), 'true\n'); } } /** * Restore the most recent snapshot from the rollback directory * back to their live positions. */ export function restoreSnapshot(state: ControlPlaneState): void { const rollbackDir = resolveRollbackDir(); if (!hasSnapshot()) { throw new Error("No rollback snapshot available"); } // Restore known files for (const rel of SNAPSHOT_FILES) { const src = join(rollbackDir, rel); const dest = join(state.homeDir, rel); safeCopy(src, dest); } // Restore system/stack/core.compose.yml const srcCoreCompose = join(rollbackDir, "system/stack/core.compose.yml"); if (existsSync(srcCoreCompose)) { safeCopy(srcCoreCompose, join(state.homeDir, "system/stack/core.compose.yml")); } clearArmedSnapshot(); } /** * Check whether a rollback snapshot exists. */ export function hasSnapshot(): boolean { const rollbackDir = resolveRollbackDir(); return existsSync(join(rollbackDir, SNAPSHOT_TS_FILE)); } export function hasArmedSnapshot(): boolean { return existsSync(join(resolveRollbackDir(), SNAPSHOT_ARMED_FILE)); } export function clearArmedSnapshot(): void { rmSync(join(resolveRollbackDir(), SNAPSHOT_ARMED_FILE), { force: true }); } /** * Read the timestamp of the most recent snapshot. */ export function snapshotTimestamp(): string | null { const tsFile = join(resolveRollbackDir(), SNAPSHOT_TS_FILE); if (!existsSync(tsFile)) return null; return readFileSync(tsFile, "utf-8").trim(); }