/** * Materialization helpers for the `cross_module_read` privilege. * * Phase 2 of openspec/specs/management-server-backup/spec.md. When an allow-listed * module (today: only celilo-mgmt) declares `cross_module_read` in its * `requires.capabilities`, its on_backup hook receives a * `cross_module_root` input pointing at a directory mirroring OTHER * modules' generated/terraform/ trees plus an index.json enumerating * deployed modules. On restore, the symmetric `cross_module_write_root` * input gives the hook a staging dir that the framework atomically * applies back onto live storage. * * The privilege is enforced in two places: * 1. validatePrivilegedCapabilities (manifest/validate.ts) — refuses * modules outside the allow-list at import time. * 2. moduleHasCrossModuleRead (here) — returns true only for manifests * that actually declared the requirement. Belt-and-suspenders so a * bug in import-time validation can't silently grant the privilege * to a module that didn't ask. */ import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { join } from 'node:path'; import { getModuleStoragePath } from '../config/paths'; import { getDb } from '../db/client'; import { modules } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; const CROSS_MODULE_READ_NAME = 'cross_module_read'; /** * True if the manifest declares cross_module_read (in requires or optional). * Note: this DOES NOT enforce the allow-list — that's the validator's job * at import time. By the time we're calling this at backup/restore time, * the module is in the DB, which means it passed validation. */ export function moduleHasCrossModuleRead(manifest: ModuleManifest): boolean { const declaresIn = (caps: { name: string }[] | undefined): boolean => (caps ?? []).some((c) => c.name === CROSS_MODULE_READ_NAME); return declaresIn(manifest.requires?.capabilities) || declaresIn(manifest.optional?.capabilities); } export interface CrossModuleIndexEntry { id: string; version: string; terraformStateDir: string; // relative path inside cross_module_root } export interface CrossModuleIndex { /** Schema version of the index.json shape — bumps independently of the envelope schema. */ schemaVersion: '1.0'; generatedAt: string; // ISO 8601 modules: CrossModuleIndexEntry[]; } /** * Populate a read-only mirror of every deployed module's * generated/terraform/ tree under `/modules//terraform/`, * plus an index.json describing them. Excludes the calling module * itself (the hook reads ITS OWN data via the normal backup_dir * mechanism, not via this mirror). * * Returns the path to the root directory — callers pass this to the * hook as the `cross_module_root` input. */ export function materializeCrossModuleRoot(rootDir: string, excludeModuleId: string): string { mkdirSync(rootDir, { recursive: true }); const modulesDir = join(rootDir, 'modules'); mkdirSync(modulesDir, { recursive: true }); const db = getDb(); const allModules = db.select().from(modules).all(); const storageRoot = getModuleStoragePath(); const entries: CrossModuleIndexEntry[] = []; for (const mod of allModules) { if (mod.id === excludeModuleId) continue; const liveTfDir = join(storageRoot, mod.id, 'generated', 'terraform'); if (!existsSync(liveTfDir)) continue; // not deployed (no TF state to mirror) const mirroredTfDir = join(modulesDir, mod.id, 'terraform'); mkdirSync(mirroredTfDir, { recursive: true }); copyDirShallow(liveTfDir, mirroredTfDir); entries.push({ id: mod.id, version: mod.version, terraformStateDir: `modules/${mod.id}/terraform`, }); } const index: CrossModuleIndex = { schemaVersion: '1.0', generatedAt: new Date().toISOString(), modules: entries, }; writeFileSync(join(rootDir, 'index.json'), JSON.stringify(index, null, 2)); return rootDir; } /** * After on_restore returns successfully, atomically apply each module's * staged terraform/ subtree back to its live storage path. * * Atomicity strategy: * - Each module's restore happens as a rename(live → live.old) + rename(staged → live). * - On any error mid-loop, rename(live.old → live) for the modules already touched. * - At the end of a successful sweep, rm -rf the live.old siblings. * * If the staging dir is empty or doesn't exist, this is a no-op. */ export interface ApplyResult { applied: string[]; // module IDs whose state was replaced skipped: string[]; // module IDs whose staged dir was empty/missing } export function applyCrossModuleWriteRoot(stagingRootDir: string): ApplyResult { const stagingModulesDir = join(stagingRootDir, 'modules'); if (!existsSync(stagingModulesDir)) { return { applied: [], skipped: [] }; } const storageRoot = getModuleStoragePath(); const applied: string[] = []; const skipped: string[] = []; const rollback: Array<{ liveDir: string; backupDir: string }> = []; try { for (const moduleIdEntry of readdirSync(stagingModulesDir, { withFileTypes: true })) { if (!moduleIdEntry.isDirectory()) continue; const moduleId = moduleIdEntry.name; const stagedTfDir = join(stagingModulesDir, moduleId, 'terraform'); if (!existsSync(stagedTfDir)) { skipped.push(moduleId); continue; } const liveGeneratedDir = join(storageRoot, moduleId, 'generated'); mkdirSync(liveGeneratedDir, { recursive: true }); const liveTfDir = join(liveGeneratedDir, 'terraform'); const backupTfDir = `${liveTfDir}.cross-module-restore-old`; // Move the current live dir aside (if any) so we can roll back on // failure. The two renames + the staged-to-live rename together are // as close to atomic as POSIX gives us. if (existsSync(liveTfDir)) { renameSync(liveTfDir, backupTfDir); } try { renameSync(stagedTfDir, liveTfDir); } catch (err) { // staged → live failed; restore the old dir if we moved it aside. if (existsSync(backupTfDir)) { renameSync(backupTfDir, liveTfDir); } throw err; } rollback.push({ liveDir: liveTfDir, backupDir: backupTfDir }); applied.push(moduleId); } } catch (err) { // Roll back every successful module first, then rethrow. for (const entry of rollback) { try { // Remove the freshly-restored live dir rmSync(entry.liveDir, { recursive: true, force: true }); if (existsSync(entry.backupDir)) { renameSync(entry.backupDir, entry.liveDir); } } catch { // Best-effort rollback; the underlying error matters more. } } throw err; } // Success — clean up the backup dirs. for (const entry of rollback) { try { if (existsSync(entry.backupDir)) { rmSync(entry.backupDir, { recursive: true, force: true }); } } catch { // Stale .cross-module-restore-old dir is harmless; will be cleaned // on next restore. } } return { applied, skipped }; } /** * Recursive copy of every file in `srcDir` into `destDir`, EXCEPT the * `.terraform/` cache directory. Mirrors the exact tree shape (no flattening). * * What travels: the state itself — terraform.tfstate, terraform.tfstate.backup, * and the provider lock file `.terraform.lock.hcl` (a top-level FILE, not inside * `.terraform/`). What does NOT: `.terraform/` — the provider binary cache * (~19 MB per proxmox provider) and the module cache, both reconstructible with * `terraform init` on restore. Including it bloated the backup envelope by ~95 MB * across the fleet and dragged the S3 upload past 12 minutes (ISS-0015). Restore * re-fetches providers via `terraform init`. */ function copyDirShallow(srcDir: string, destDir: string): void { for (const entry of readdirSync(srcDir, { withFileTypes: true })) { // Skip the terraform provider/module cache — re-fetchable, not state. if (entry.isDirectory() && entry.name === '.terraform') continue; const srcPath = join(srcDir, entry.name); const destPath = join(destDir, entry.name); if (entry.isDirectory()) { mkdirSync(destPath, { recursive: true }); copyDirShallow(srcPath, destPath); } else if (entry.isFile()) { copyFileSync(srcPath, destPath); } // Symlinks/sockets/etc are skipped intentionally — TF state should // never contain them, and silently following symlinks across a // privilege boundary is a footgun. } }