/** * Terraform plan drift check. * * For each installed module that has a generated terraform directory, * run `terraform plan -detailed-exitcode -no-color` and parse the * summary line ("Plan: 1 to add, 0 to change, 2 to destroy."). A plan * that includes destructive operations (delete/replace) produces a * `blocked` finding — `system update` would otherwise silently let * Terraform delete an LXC the user didn't expect to lose. A * non-destructive plan (additions / in-place changes only) produces * a `drift` finding. * * The shell runner is injectable so unit tests assert on parsing * logic without invoking the real `terraform` binary. */ import type { DriftFinding } from './types'; export interface TerraformPlanModule { id: string; /** Path to the module's generated terraform dir, or null if not yet generated. */ terraformDir: string | null; /** * Provider credentials to inject as `TF_VAR_*` env vars. Empty * for machine-pool deployments; populated from container-service * credentials for Proxmox/DigitalOcean modules. Mirrors what * `module deploy` injects so the audit's plan check sees the same * world the deployer would. */ envVars?: Record; } export interface TerraformPlanRunResult { exitCode: number; stdout: string; stderr: string; } export type TerraformPlanRunner = ( terraformDir: string, envVars?: Record, ) => Promise; export interface TerraformPlanAuditDeps { modules: TerraformPlanModule[]; run: TerraformPlanRunner; } /** * Parses the typical "Plan: N to add, M to change, K to destroy." * line in `terraform plan` output. Returns null if no Plan: line is * found (e.g., empty plan, or Terraform output format changed). */ export interface PlanSummary { add: number; change: number; destroy: number; } // Built from a string at runtime so biome's "control character in // regex literal" rule doesn't flag the embedded ESC byte. ANSI CSI // sequences are `ESC [ params m`. const ANSI_COLOR_RE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g'); export function parsePlanSummary(stdout: string): PlanSummary | null { // Terraform may colorize even with -no-color in some setups; strip ANSI defensively. const stripped = stdout.replace(ANSI_COLOR_RE, ''); const match = stripped.match( /Plan:\s+(\d+)\s+to\s+add,\s+(\d+)\s+to\s+change,\s+(\d+)\s+to\s+destroy\b/, ); if (!match) { // Empty plan — terraform sometimes prints "No changes." instead. if (/No changes\.|Your infrastructure matches the configuration/.test(stripped)) { return { add: 0, change: 0, destroy: 0 }; } return null; } return { add: Number.parseInt(match[1], 10), change: Number.parseInt(match[2], 10), destroy: Number.parseInt(match[3], 10), }; } export async function auditTerraformPlan(deps: TerraformPlanAuditDeps): Promise { const findings: DriftFinding[] = []; for (const m of deps.modules) { if (m.terraformDir === null) continue; const result = await deps.run(m.terraformDir, m.envVars); // Exit code 0 = no changes; 2 = changes pending; anything else = error if (result.exitCode !== 0 && result.exitCode !== 2) { findings.push({ category: 'terraform_plan', severity: 'drift', code: 'terraform_plan_failed', message: `${m.id}: terraform plan failed (exit ${result.exitCode})`, details: (result.stderr || result.stdout).slice(0, 500), remediation: [ 'Inspect the error above, then re-run terraform plan in', m.terraformDir, 'to debug. Common causes: missing provider credentials,', 'broken state, expired tokens.', ].join('\n'), // Investigatory; no single celilo command resolves it. actionable: false, subject: m.id, }); continue; } const summary = parsePlanSummary(result.stdout); if (!summary) { // Terraform exited zero and celilo could not read its answer. That is not // "no drift" — it is no measurement, and it used to be indistinguishable // from a clean plan (D7). findings.push({ category: 'terraform_plan', severity: 'unmeasured', code: 'terraform_plan_unparseable', message: `${m.id}: terraform plan succeeded but its summary could not be parsed, so infrastructure drift is unknown`, details: result.stdout.slice(0, 500), remediation: `Run terraform plan in ${m.terraformDir} and read it directly. This records that the comparison did not happen, not that the infrastructure matches.`, actionable: false, subject: m.id, }); continue; } if (summary.add === 0 && summary.change === 0 && summary.destroy === 0) { continue; } if (summary.destroy > 0) { findings.push({ category: 'terraform_plan', severity: 'blocked', code: 'terraform_plan_destructive', message: `${m.id}: terraform plan would destroy ${summary.destroy} resource${summary.destroy === 1 ? '' : 's'} (+${summary.add} ~${summary.change})`, remediation: [ 'Review the plan carefully. If the destruction is intended,', 'run:', ' celilo system update --allow-destructive', 'Otherwise investigate why the plan wants to destroy', 'resources before proceeding.', ].join('\n'), // The opt-in flag and review step make this a deliberately // non-one-click finding — surfacing the modal would let users // accidentally fire a destructive update. actionable: false, subject: m.id, }); } else { findings.push({ category: 'terraform_plan', severity: 'drift', code: 'terraform_plan_pending', message: `${m.id}: terraform plan has +${summary.add} ~${summary.change} changes pending`, remediation: `celilo module deploy ${m.id}`, actionable: true, subject: m.id, }); } } return findings; }