/** * Terraform Plan Safety Validation * * Parses Terraform plan output and validates that only safe operations are planned. * * Safety Rules: * - Only CREATE operations allowed * - Only whitelisted provider resources allowed: * - Proxmox: proxmox_lxc, proxmox_vm * - Digital Ocean: digitalocean_droplet, digitalocean_* * - No UPDATE operations (coming in future phase) * - No DELETE operations * - No REPLACE operations */ /** * Allowed Terraform resource type prefixes * Only resources from these providers can be created */ const ALLOWED_RESOURCE_PREFIXES = ['proxmox_lxc', 'proxmox_vm', 'digitalocean_']; export interface PlanSafetyResult { safe: boolean; error?: string; actions: TerraformAction[]; } export interface TerraformAction { resourceType: string; resourceName: string; action: 'create' | 'update' | 'delete' | 'replace'; } /** * Parse and validate Terraform plan for safety * Policy function - validates plan adheres to safety rules * * @param planOutput - Terraform plan output (from terraform plan -no-color) * @returns Safety validation result */ export function validateTerraformPlanSafety(planOutput: string): PlanSafetyResult { const actions = parseTerraformPlan(planOutput); // Check each action against safety rules for (const action of actions) { // Rule 1: Only whitelisted provider resources allowed const isAllowed = ALLOWED_RESOURCE_PREFIXES.some((prefix) => action.resourceType.startsWith(prefix), ); if (!isAllowed) { const allowedList = ALLOWED_RESOURCE_PREFIXES.join(', '); return { safe: false, error: `Unexpected resource type: ${action.resourceType}\nOnly resources from approved providers are allowed: ${allowedList}`, actions, }; } // Rule 2: CREATE and in-place UPDATE are allowed; REPLACE and DELETE are // refused. (ISS-0055) An in-place update — the lifecycle-ignored // computed-attribute redeploy, or a deliberate memory/cpu resize — is safe // and must go through, so the management plane can update containers in // place. Only destroy+recreate (replace) and delete are data-loss // operations we hard-block. The parser matches `replace` before `update`, // so a "must be replaced" plan is still classified as replace and refused. if (action.action !== 'create' && action.action !== 'update') { return { safe: false, error: formatUnsafeOperationError(action), actions, }; } } return { safe: true, actions }; } /** * Parse Terraform plan output to extract resource actions * Policy function - pure parsing logic * * Terraform plan format examples: * # proxmox_lxc.caddy will be created * # proxmox_lxc.caddy will be updated in-place * # proxmox_lxc.caddy will be updated in-place (some changes) * # proxmox_lxc.caddy must be replaced * # proxmox_lxc.caddy will be destroyed * # proxmox_lxc.caddy must be replaced (forces new resource) * * @param planOutput - Terraform plan output text * @returns Array of parsed actions */ export function parseTerraformPlan(planOutput: string): TerraformAction[] { const actions: TerraformAction[] = []; const lines = planOutput.split('\n'); // Regex patterns for Terraform plan actions const createPattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be created$/; const updatePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be updated/; const replacePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+must be replaced/; const deletePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be destroyed$/; for (const line of lines) { const trimmed = line.trim(); // Try each pattern in order (replace must come before update since replace contains "must be") let match = trimmed.match(replacePattern); if (match) { actions.push({ resourceType: match[1], resourceName: match[2], action: 'replace', }); continue; } match = trimmed.match(createPattern); if (match) { actions.push({ resourceType: match[1], resourceName: match[2], action: 'create', }); continue; } match = trimmed.match(updatePattern); if (match) { actions.push({ resourceType: match[1], resourceName: match[2], action: 'update', }); continue; } match = trimmed.match(deletePattern); if (match) { actions.push({ resourceType: match[1], resourceName: match[2], action: 'delete', }); } } return actions; } /** * Format error message for unsafe operations * Presentation function - formats output for user * * @param action - Terraform action that failed validation * @returns Formatted error message */ function formatUnsafeOperationError(action: TerraformAction): string { const lines = [ `Unsafe operation: ${action.action} on ${action.resourceType}.${action.resourceName}`, '', 'Only CREATE operations are currently allowed.', ]; if (action.action === 'update') { lines.push('UPDATE operations require explicit approval.'); lines.push(''); lines.push('To fix: Review the changes and manually apply if safe.'); } else if (action.action === 'delete') { lines.push('DELETE operations are never auto-approved.'); lines.push(''); lines.push('To fix: Remove the resource manually or use terraform destroy.'); } else if (action.action === 'replace') { lines.push('REPLACE operations require explicit approval.'); lines.push(''); lines.push('To fix: Review what triggered the replacement and decide if it should proceed.'); } return lines.join('\n'); } /** What the deploy should do with the plan computed AFTER an auto-import. */ export type ImportRetryVerdict = | { action: 'done' } | { action: 'apply' } | { action: 'refuse'; error: string }; /** * Decide whether the apply retried after an auto-import may proceed. * * An import REPLACES the plan that `deployTerraform` validated before its first * apply. That plan described a create; the plan computed after the import can * describe a REPLACE, because `ostemplate` and `rootfs[0].storage` do not * round-trip through an import and each forces one. Nothing re-validated it, so * the retry applied a destroy that `validateTerraformPlanSafety` exists to * forbid — and a redeploy destroyed signal's LXC on a converged fleet * (celilo#1374). The gate was present; the import walked around it. * * Pure (Rule 10) so the refusal is testable without terraform: the caller does * the I/O and hands the exit code and output here. */ export function planAfterImportVerdict(exitCode: number, planOutput: string): ImportRetryVerdict { // 0 = no changes. The import alone reconciled state with reality, which is // the outcome we want: adopt the resource and apply nothing. if (exitCode === 0) { return { action: 'done' }; } // 2 = changes pending. Anything else is an error, and an unreadable plan is // never a licence to apply (D7: no measurement is not "no drift"). if (exitCode !== 2) { return { action: 'refuse', error: `Refusing to apply after import: terraform plan failed (exit ${exitCode}).\n${planOutput.slice(0, 500)}`, }; } const safety = validateTerraformPlanSafety(planOutput); if (!safety.safe) { return { action: 'refuse', error: [ 'Refusing to apply the plan computed after importing an existing resource.', 'The import adopted infrastructure that already exists, and applying this', 'plan would destroy it rather than adopt it.', '', safety.error ?? 'unsafe plan', ].join('\n'), }; } return { action: 'apply' }; }