#!/usr/bin/env python3 """ Terraform Plan Summarizer for Cloudflare GitOps Phase 6 - PR Workflows Parses terraform plan JSON output and generates: - Risk-scored change summaries - Markdown reports for MR comments - Compliance violation flags - Affected zone analysis """ import json import os import subprocess import sys from dataclasses import dataclass, field from enum import IntEnum from fnmatch import fnmatch from pathlib import Path from typing import Any, Dict, List, Optional, Set try: import yaml except ImportError: print("ERROR: pip install pyyaml", file=sys.stderr) sys.exit(1) HERE = Path(__file__).resolve().parent CONFIG_PATH = HERE / "config.yml" class RiskLevel(IntEnum): """Risk levels for changes""" LOW = 0 MEDIUM = 1 HIGH = 2 CRITICAL = 3 @classmethod def from_string(cls, s: str) -> "RiskLevel": return cls[s.upper()] def __str__(self) -> str: return self.name @dataclass class ResourceChange: """Represents a single resource change from terraform plan""" address: str resource_type: str name: str actions: List[str] before: Optional[Dict[str, Any]] = None after: Optional[Dict[str, Any]] = None risk_level: RiskLevel = RiskLevel.LOW category: str = "other" compliance_flags: List[str] = field(default_factory=list) @dataclass class PlanSummary: """Aggregated plan summary""" total_changes: int = 0 by_action: Dict[str, int] = field(default_factory=dict) by_risk: Dict[str, int] = field(default_factory=dict) by_category: Dict[str, int] = field(default_factory=dict) changes: List[ResourceChange] = field(default_factory=list) affected_zones: Set[str] = field(default_factory=set) compliance_violations: List[str] = field(default_factory=list) overall_risk: RiskLevel = RiskLevel.LOW def load_config() -> Dict[str, Any]: """Load gitops configuration""" if not CONFIG_PATH.exists(): raise FileNotFoundError(f"Config not found: {CONFIG_PATH}") with open(CONFIG_PATH) as f: config = yaml.safe_load(f) # Expand environment variables def expand_env(obj): if isinstance(obj, str): if obj.startswith("${") and obj.endswith("}"): var = obj[2:-1] default = None if ":-" in var: var, default = var.split(":-", 1) return os.environ.get(var, default) return obj elif isinstance(obj, dict): return {k: expand_env(v) for k, v in obj.items()} elif isinstance(obj, list): return [expand_env(i) for i in obj] return obj return expand_env(config) def run_terraform_show(plan_path: Path, tf_dir: Path) -> Dict[str, Any]: """Run terraform show -json on plan file""" result = subprocess.run( ["terraform", "show", "-json", str(plan_path)], cwd=tf_dir, capture_output=True, text=True, ) if result.returncode != 0: print(f"terraform show failed: {result.stderr}", file=sys.stderr) sys.exit(1) return json.loads(result.stdout) def get_resource_category(cfg: Dict[str, Any], resource_type: str) -> tuple[str, RiskLevel]: """Determine category and base risk for a resource type""" risk_cfg = cfg.get("risk", {}) for category, cat_cfg in risk_cfg.items(): if category in ("actions", "levels"): continue resource_types = cat_cfg.get("resource_types", []) for pattern in resource_types: if fnmatch(resource_type, pattern): base_risk = cat_cfg.get("base_risk", "low") return category, RiskLevel.from_string(base_risk) return "other", RiskLevel.LOW def calculate_risk( cfg: Dict[str, Any], resource_type: str, actions: List[str], ) -> tuple[str, RiskLevel]: """Calculate risk level for a change""" category, base_risk = get_resource_category(cfg, resource_type) risk_cfg = cfg.get("risk", {}) actions_cfg = risk_cfg.get("actions", {}) # Find highest action modifier max_modifier = 0 for action in actions: action_cfg = actions_cfg.get(action, {}) modifier = action_cfg.get("modifier", 0) max_modifier = max(max_modifier, modifier) # Calculate final risk final_risk_value = min(base_risk.value + max_modifier, RiskLevel.CRITICAL.value) final_risk = RiskLevel(final_risk_value) return category, final_risk def check_compliance( cfg: Dict[str, Any], resource_type: str, actions: List[str], before: Optional[Dict], after: Optional[Dict], ) -> List[str]: """Check for compliance framework violations""" violations = [] compliance_cfg = cfg.get("compliance", {}) frameworks = compliance_cfg.get("frameworks", []) for framework in frameworks: name = framework.get("name", "Unknown") triggers = framework.get("triggers", []) for trigger in triggers: trigger_types = trigger.get("resource_types", []) trigger_actions = trigger.get("actions", []) trigger_fields = trigger.get("fields", []) # Check resource type match type_match = any(fnmatch(resource_type, t) for t in trigger_types) if not type_match: continue # Check action match (if specified) if trigger_actions and not any(a in trigger_actions for a in actions): continue # Check field changes (if specified) if trigger_fields and before and after: field_changed = any( before.get(f) != after.get(f) for f in trigger_fields ) if not field_changed: continue violations.append(name) return list(set(violations)) def extract_zone(change: ResourceChange) -> Optional[str]: """Extract zone name from resource if available""" # Check after state first, then before state = change.after or change.before or {} # Common zone identifiers for key in ("zone", "zone_id", "zone_name"): if key in state: return str(state[key]) # Try to extract from address if "zone" in change.address.lower(): parts = change.address.split(".") for i, part in enumerate(parts): if "zone" in part.lower() and i + 1 < len(parts): return parts[i + 1] return None def parse_plan(plan_json: Dict[str, Any], cfg: Dict[str, Any]) -> PlanSummary: """Parse terraform plan JSON into summary""" summary = PlanSummary() resource_changes = plan_json.get("resource_changes", []) for rc in resource_changes: change = rc.get("change", {}) actions = change.get("actions", []) # Skip no-op changes if actions == ["no-op"]: continue resource_type = rc.get("type", "unknown") address = rc.get("address", "unknown") name = rc.get("name", "unknown") before = change.get("before") after = change.get("after") # Calculate risk category, risk_level = calculate_risk(cfg, resource_type, actions) # Check compliance compliance_flags = check_compliance( cfg, resource_type, actions, before, after ) resource_change = ResourceChange( address=address, resource_type=resource_type, name=name, actions=actions, before=before, after=after, risk_level=risk_level, category=category, compliance_flags=compliance_flags, ) summary.changes.append(resource_change) # Update counts summary.total_changes += 1 for action in actions: summary.by_action[action] = summary.by_action.get(action, 0) + 1 risk_name = str(risk_level) summary.by_risk[risk_name] = summary.by_risk.get(risk_name, 0) + 1 summary.by_category[category] = summary.by_category.get(category, 0) + 1 # Track zones zone = extract_zone(resource_change) if zone: summary.affected_zones.add(zone) # Track compliance summary.compliance_violations.extend(compliance_flags) # Calculate overall risk if summary.by_risk.get("CRITICAL", 0) > 0: summary.overall_risk = RiskLevel.CRITICAL elif summary.by_risk.get("HIGH", 0) > 0: summary.overall_risk = RiskLevel.HIGH elif summary.by_risk.get("MEDIUM", 0) > 0: summary.overall_risk = RiskLevel.MEDIUM else: summary.overall_risk = RiskLevel.LOW # Deduplicate compliance summary.compliance_violations = list(set(summary.compliance_violations)) return summary def format_markdown(summary: PlanSummary, cfg: Dict[str, Any]) -> str: """Format summary as Markdown for MR comments""" ci_cfg = cfg.get("ci", {}) include = ci_cfg.get("include", {}) collapse_threshold = ci_cfg.get("collapse_threshold", 10) lines = [] # Header with risk badge risk_emoji = { RiskLevel.LOW: "🟢", RiskLevel.MEDIUM: "🟡", RiskLevel.HIGH: "🟠", RiskLevel.CRITICAL: "🔴", } emoji = risk_emoji.get(summary.overall_risk, "⚪") lines.append(f"## {emoji} Terraform Plan Summary") lines.append("") # Risk summary if include.get("risk_summary", True): lines.append(f"**Overall Risk:** {emoji} **{summary.overall_risk}**") lines.append(f"**Total Changes:** `{summary.total_changes}`") lines.append("") # Action counts if include.get("action_counts", True): actions_str = ", ".join( f"{k}={v}" for k, v in sorted(summary.by_action.items()) ) lines.append(f"**Actions:** {actions_str}") lines.append("") # Category breakdown if summary.by_category: lines.append("**By Category:**") for cat, count in sorted(summary.by_category.items()): lines.append(f"- {cat}: {count}") lines.append("") # Affected zones if include.get("affected_zones", True) and summary.affected_zones: zones = ", ".join(f"`{z}`" for z in sorted(summary.affected_zones)) lines.append(f"**Affected Zones:** {zones}") lines.append("") # Compliance flags if include.get("compliance_flags", True) and summary.compliance_violations: lines.append("**Compliance Impact:**") for framework in sorted(set(summary.compliance_violations)): lines.append(f"- ⚠️ {framework}") lines.append("") # Resource table if include.get("resource_table", True) and summary.changes: lines.append("### Resource Changes") lines.append("") # Collapse if many changes if len(summary.changes) > collapse_threshold: lines.append("
") lines.append(f"Show {len(summary.changes)} changes") lines.append("") lines.append("| Resource | Actions | Risk | Compliance |") lines.append("|----------|---------|------|------------|") # Sort by risk (highest first) sorted_changes = sorted( summary.changes, key=lambda c: c.risk_level.value, reverse=True, ) for change in sorted_changes[:50]: # Cap at 50 actions = ",".join(change.actions) risk = str(change.risk_level) compliance = ",".join(change.compliance_flags) if change.compliance_flags else "-" lines.append( f"| `{change.address}` | `{actions}` | **{risk}** | {compliance} |" ) if len(summary.changes) > 50: lines.append("") lines.append(f"_... {len(summary.changes) - 50} more resources omitted_") if len(summary.changes) > collapse_threshold: lines.append("") lines.append("
") lines.append("") # Dashboard links dashboard_links = ci_cfg.get("dashboard_links", {}) if dashboard_links: lines.append("### Quick Links") for name, url in dashboard_links.items(): lines.append(f"- [{name.title()}]({url})") lines.append("") return "\n".join(lines) def format_json(summary: PlanSummary) -> str: """Format summary as JSON for programmatic use""" return json.dumps( { "total_changes": summary.total_changes, "overall_risk": str(summary.overall_risk), "by_action": summary.by_action, "by_risk": summary.by_risk, "by_category": summary.by_category, "affected_zones": list(summary.affected_zones), "compliance_violations": summary.compliance_violations, "changes": [ { "address": c.address, "resource_type": c.resource_type, "actions": c.actions, "risk_level": str(c.risk_level), "category": c.category, "compliance_flags": c.compliance_flags, } for c in summary.changes ], }, indent=2, ) def main(): """Main entry point""" import argparse parser = argparse.ArgumentParser( description="Summarize Terraform plan for GitOps" ) parser.add_argument( "--plan-file", help="Path to plan file (default: from config)", ) parser.add_argument( "--plan-json", help="Path to pre-generated plan JSON (skip terraform show)", ) parser.add_argument( "--format", choices=["markdown", "json"], default="markdown", help="Output format", ) parser.add_argument( "--tf-dir", help="Terraform working directory", ) args = parser.parse_args() # Load config cfg = load_config() tf_cfg = cfg.get("terraform", {}) # Determine paths tf_dir = Path(args.tf_dir) if args.tf_dir else HERE.parent / tf_cfg.get("working_dir", "terraform") plan_file = args.plan_file or tf_cfg.get("plan_file", "plan.tfplan") plan_path = tf_dir / plan_file # Get plan JSON if args.plan_json: with open(args.plan_json) as f: plan_json = json.load(f) else: plan_json = run_terraform_show(plan_path, tf_dir) # Parse and summarize summary = parse_plan(plan_json, cfg) # Output if args.format == "json": print(format_json(summary)) else: print(format_markdown(summary, cfg)) if __name__ == "__main__": main()