Files
vm-cloudflare/gitops/plan_summarizer.py
Vault Sovereign 37a867c485 Initial commit: Cloudflare infrastructure with WAF Intelligence
- Complete Cloudflare Terraform configuration (DNS, WAF, tunnels, access)
- WAF Intelligence MCP server with threat analysis and ML classification
- GitOps automation with PR workflows and drift detection
- Observatory monitoring stack with Prometheus/Grafana
- IDE operator rules for governed development
- Security playbooks and compliance frameworks
- Autonomous remediation and state reconciliation
2025-12-16 18:31:53 +00:00

488 lines
14 KiB
Python

#!/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("<details>")
lines.append(f"<summary>Show {len(summary.changes)} changes</summary>")
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("</details>")
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()