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
This commit is contained in:
487
gitops/plan_summarizer.py
Normal file
487
gitops/plan_summarizer.py
Normal file
@@ -0,0 +1,487 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user