"""Terraform validation runner. Runs terraform init, validate, and plan against generated output to verify syntactic correctness and detect infrastructure drift. Includes auto-correction logic that attempts to fix common validation errors heuristically. """ import json import re import shutil import subprocess from pathlib import Path from iac_reverse.models import PlannedChange, ValidationError, ValidationResult class Validator: """Runs Terraform commands to validate generated IaC output. Validates generated .tf and .tfstate files by running terraform init, terraform validate, and terraform plan. Reports validation errors and planned changes (drift) back to the caller. When validation fails, attempts heuristic-based auto-corrections up to max_correction_attempts times before reporting failure. """ def validate( self, output_dir: str, max_correction_attempts: int = 3 ) -> ValidationResult: """Run terraform init, validate, and plan against the output directory. After terraform validate fails, attempts auto-correction of common errors (unknown attributes, missing required blocks, syntax issues) up to max_correction_attempts times. Re-validates after each correction. Args: output_dir: Path to directory containing generated .tf and .tfstate files. max_correction_attempts: Maximum number of auto-correction attempts before reporting failure. Defaults to 3. Returns: ValidationResult with init/validate/plan success flags, any planned changes (drift), validation errors, and the number of correction attempts made. """ # Check terraform binary availability terraform_bin = shutil.which("terraform") if terraform_bin is None: return ValidationResult( init_success=False, validate_success=False, plan_success=False, errors=[ ValidationError( file="", message=( "Terraform binary not found. " "Terraform is required for validation. " "Please install Terraform and ensure it is on your PATH." ), ) ], correction_attempts=0, ) output_path = Path(output_dir) errors: list[ValidationError] = [] planned_changes: list[PlannedChange] = [] # Run terraform init init_success = self._run_init(output_path, errors) if not init_success: return ValidationResult( init_success=False, validate_success=False, plan_success=False, errors=errors, correction_attempts=0, ) # Run terraform validate with auto-correction loop correction_attempts = 0 validate_success = self._run_validate(output_path, errors) while not validate_success and correction_attempts < max_correction_attempts: # Attempt to correct the errors corrected = self._attempt_correction(output_path, errors) if not corrected: # No corrections could be applied, stop trying break correction_attempts += 1 # Re-validate after correction errors = [] validate_success = self._run_validate(output_path, errors) if not validate_success: return ValidationResult( init_success=True, validate_success=False, plan_success=False, errors=errors, correction_attempts=correction_attempts, ) # Run terraform plan plan_success = self._run_plan(output_path, errors, planned_changes) return ValidationResult( init_success=True, validate_success=True, plan_success=plan_success, planned_changes=planned_changes, errors=errors, correction_attempts=correction_attempts, ) def _run_init( self, output_path: Path, errors: list[ValidationError] ) -> bool: """Run terraform init in the output directory. Returns True if init succeeds, False otherwise. """ try: result = subprocess.run( ["terraform", "init", "-no-color"], cwd=str(output_path), capture_output=True, text=True, timeout=120, ) if result.returncode != 0: errors.append( ValidationError( file="", message=f"terraform init failed: {result.stderr.strip()}", ) ) return False return True except subprocess.TimeoutExpired: errors.append( ValidationError( file="", message="terraform init timed out after 120 seconds", ) ) return False except OSError as e: errors.append( ValidationError( file="", message=f"Failed to execute terraform init: {e}", ) ) return False def _run_validate( self, output_path: Path, errors: list[ValidationError] ) -> bool: """Run terraform validate with JSON output and parse errors. Returns True if validation passes, False otherwise. """ try: result = subprocess.run( ["terraform", "validate", "-json"], cwd=str(output_path), capture_output=True, text=True, timeout=60, ) return self._parse_validate_output(result.stdout, errors) except subprocess.TimeoutExpired: errors.append( ValidationError( file="", message="terraform validate timed out after 60 seconds", ) ) return False except OSError as e: errors.append( ValidationError( file="", message=f"Failed to execute terraform validate: {e}", ) ) return False def _parse_validate_output( self, stdout: str, errors: list[ValidationError] ) -> bool: """Parse terraform validate JSON output. Expected format: { "valid": true/false, "error_count": N, "diagnostics": [ { "severity": "error", "summary": "...", "detail": "...", "range": { "filename": "main.tf", "start": {"line": 1, "column": 1}, ... } } ] } """ try: data = json.loads(stdout) except (json.JSONDecodeError, TypeError): errors.append( ValidationError( file="", message="Failed to parse terraform validate output as JSON", ) ) return False if data.get("valid", False): return True diagnostics = data.get("diagnostics", []) for diag in diagnostics: if diag.get("severity") != "error": continue filename = "" line = None range_info = diag.get("range") if range_info: filename = range_info.get("filename", "") start = range_info.get("start") if start: line = start.get("line") summary = diag.get("summary", "") detail = diag.get("detail", "") message = summary if detail: message = f"{summary}: {detail}" errors.append( ValidationError(file=filename, message=message, line=line) ) return False def _run_plan( self, output_path: Path, errors: list[ValidationError], planned_changes: list[PlannedChange], ) -> bool: """Run terraform plan with JSON output and parse planned changes. Returns True if zero changes are planned, False otherwise. """ try: result = subprocess.run( ["terraform", "plan", "-json", "-no-color"], cwd=str(output_path), capture_output=True, text=True, timeout=300, ) if result.returncode not in (0, 2): # returncode 2 means changes are planned, which is valid output errors.append( ValidationError( file="", message=f"terraform plan failed: {result.stderr.strip()}", ) ) return False return self._parse_plan_output( result.stdout, errors, planned_changes ) except subprocess.TimeoutExpired: errors.append( ValidationError( file="", message="terraform plan timed out after 300 seconds", ) ) return False except OSError as e: errors.append( ValidationError( file="", message=f"Failed to execute terraform plan: {e}", ) ) return False def _parse_plan_output( self, stdout: str, errors: list[ValidationError], planned_changes: list[PlannedChange], ) -> bool: """Parse terraform plan JSON output (streaming JSON lines format). Terraform plan -json outputs one JSON object per line. We look for lines with type "resource_drift" or "planned_change" to identify changes, and "change_summary" for the overall result. Each resource change line looks like: { "type": "planned_change", "change": { "resource": { "addr": "aws_instance.example" }, "action": "create" | "update" | "delete" } } """ has_changes = False for line in stdout.strip().splitlines(): line = line.strip() if not line: continue try: entry = json.loads(line) except json.JSONDecodeError: continue entry_type = entry.get("type", "") if entry_type in ("planned_change", "resource_drift"): change = entry.get("change", {}) resource = change.get("resource", {}) resource_addr = resource.get("addr", "unknown") action = change.get("action", "unknown") # Map terraform action names to our change types change_type = self._map_action_to_change_type(action) # Build details from before/after if available details = f"Action: {action}" planned_changes.append( PlannedChange( resource_address=resource_addr, change_type=change_type, details=details, ) ) has_changes = True elif entry_type == "change_summary": changes_info = entry.get("changes", {}) add = changes_info.get("add", 0) change = changes_info.get("change", 0) remove = changes_info.get("remove", 0) if add + change + remove > 0: has_changes = True # plan_success is True only when there are zero planned changes return not has_changes @staticmethod def _map_action_to_change_type(action: str) -> str: """Map terraform plan action to our change type vocabulary.""" action_map = { "create": "add", "update": "modify", "delete": "destroy", "replace": "modify", "read": "add", } return action_map.get(action, action) # ------------------------------------------------------------------ # Auto-correction logic # ------------------------------------------------------------------ def _attempt_correction( self, output_path: Path, errors: list[ValidationError] ) -> bool: """Attempt to auto-correct validation errors using heuristics. Applies corrections for: - Unknown/unsupported attributes (removes the offending line) - Missing required provider blocks (adds empty provider block) - Common syntax issues (unclosed braces, trailing commas) Args: output_path: Path to the directory containing .tf files. errors: List of validation errors to attempt to correct. Returns: True if at least one correction was applied, False otherwise. """ any_corrected = False for error in errors: corrected = self._correct_single_error(output_path, error) if corrected: any_corrected = True return any_corrected def _correct_single_error( self, output_path: Path, error: ValidationError ) -> bool: """Attempt to correct a single validation error. Returns True if a correction was applied. """ message = error.message.lower() # Handle unknown/unsupported attribute errors if self._is_unknown_attribute_error(message): return self._remove_attribute_line(output_path, error) # Handle missing required provider block if self._is_missing_provider_error(message): return self._add_missing_provider_block(output_path, error) # Handle syntax errors (unclosed braces, trailing commas) if self._is_syntax_error(message): return self._fix_syntax_error(output_path, error) return False @staticmethod def _is_unknown_attribute_error(message: str) -> bool: """Check if the error is about an unknown or unsupported attribute.""" patterns = [ "unsupported argument", "unsupported attribute", "unknown attribute", "an argument named", "is not expected here", "no such attribute", ] return any(p in message for p in patterns) @staticmethod def _is_missing_provider_error(message: str) -> bool: """Check if the error is about a missing required provider.""" patterns = [ "missing required provider", "provider configuration not present", "no provider", "required provider", ] return any(p in message for p in patterns) @staticmethod def _is_syntax_error(message: str) -> bool: """Check if the error is a syntax error that might be fixable.""" patterns = [ "unexpected closing brace", "unclosed configuration block", "expected closing brace", "invalid character", "trailing comma", "argument or block definition required", ] return any(p in message for p in patterns) def _remove_attribute_line( self, output_path: Path, error: ValidationError ) -> bool: """Remove the line containing an unknown/unsupported attribute. If the error has file and line info, removes that specific line. Otherwise, attempts to find and remove the attribute by name from the error message. """ if not error.file: return False file_path = output_path / error.file if not file_path.exists(): return False try: lines = file_path.read_text(encoding="utf-8").splitlines() except OSError: return False if error.line is not None and 1 <= error.line <= len(lines): # Remove the specific line line_idx = error.line - 1 removed_line = lines[line_idx].strip() # Only remove if it looks like an attribute assignment if "=" in removed_line or removed_line.endswith("{"): lines.pop(line_idx) try: file_path.write_text( "\n".join(lines) + "\n", encoding="utf-8" ) return True except OSError: return False # Try to find the attribute name from the error message attr_name = self._extract_attribute_name(error.message) if attr_name: return self._remove_attribute_by_name(file_path, attr_name, lines) return False @staticmethod def _extract_attribute_name(message: str) -> str: """Extract the attribute name from an error message. Looks for patterns like: - "An argument named 'foo' is not expected here" - "Unsupported argument: foo" """ # Pattern: quoted attribute name match = re.search(r"['\"](\w+)['\"]", message) if match: return match.group(1) # Pattern: "named X is not" match = re.search(r"named\s+(\w+)\s+is", message) if match: return match.group(1) return "" @staticmethod def _remove_attribute_by_name( file_path: Path, attr_name: str, lines: list[str] ) -> bool: """Remove lines containing the given attribute assignment.""" pattern = re.compile(rf"^\s*{re.escape(attr_name)}\s*=") new_lines = [line for line in lines if not pattern.match(line)] if len(new_lines) == len(lines): return False # Nothing was removed try: file_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") return True except OSError: return False def _add_missing_provider_block( self, output_path: Path, error: ValidationError ) -> bool: """Add a missing provider block to the configuration. Extracts the provider name from the error message and creates an empty provider block in a providers.tf file. """ provider_name = self._extract_provider_name(error.message) if not provider_name: return False providers_file = output_path / "providers.tf" provider_block = f'\nprovider "{provider_name}" {{}}\n' try: if providers_file.exists(): existing = providers_file.read_text(encoding="utf-8") # Don't add if already present if f'provider "{provider_name}"' in existing: return False providers_file.write_text( existing + provider_block, encoding="utf-8" ) else: providers_file.write_text(provider_block, encoding="utf-8") return True except OSError: return False @staticmethod def _extract_provider_name(message: str) -> str: """Extract provider name from a missing provider error message. Looks for patterns like: - "Missing required provider 'aws'" - 'provider "kubernetes" configuration not present' """ match = re.search(r"provider\s+['\"](\w+)['\"]", message) if match: return match.group(1) match = re.search(r"['\"](\w+)['\"]", message) if match: return match.group(1) return "" def _fix_syntax_error( self, output_path: Path, error: ValidationError ) -> bool: """Attempt to fix common syntax errors. Handles: - Trailing commas before closing braces - Missing closing braces - Lines with 'argument or block definition required' (remove empty/bad lines) """ if not error.file: return False file_path = output_path / error.file if not file_path.exists(): return False try: content = file_path.read_text(encoding="utf-8") except OSError: return False original_content = content # Fix trailing commas before closing braces/brackets content = re.sub(r",(\s*[}\]])", r"\1", content) # Fix 'argument or block definition required' - remove empty lines # at the error location if error.line is not None and "argument or block definition required" in error.message.lower(): lines = content.splitlines() if 1 <= error.line <= len(lines): line_idx = error.line - 1 line = lines[line_idx].strip() # Remove the problematic line if it's empty or just whitespace/punctuation if not line or line in (",", ";"): lines.pop(line_idx) content = "\n".join(lines) + "\n" if content != original_content: try: file_path.write_text(content, encoding="utf-8") return True except OSError: return False return False