- 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
488 lines
14 KiB
Python
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()
|