- 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
359 lines
11 KiB
Python
359 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CI Plan Comment Bot for Cloudflare GitOps
|
|
Phase 6 - PR Workflows
|
|
|
|
Posts Terraform plan summaries as comments on Merge Requests.
|
|
Designed to run in GitLab CI/CD pipelines.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
try:
|
|
import requests
|
|
import yaml
|
|
except ImportError:
|
|
print("ERROR: pip install requests pyyaml", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
CONFIG_PATH = HERE / "config.yml"
|
|
|
|
|
|
def load_config() -> Dict[str, Any]:
|
|
"""Load gitops configuration with env expansion"""
|
|
with open(CONFIG_PATH) as f:
|
|
config = yaml.safe_load(f)
|
|
|
|
def expand_env(obj):
|
|
if isinstance(obj, str):
|
|
if obj.startswith("${") and "}" in obj:
|
|
inner = obj[2:obj.index("}")]
|
|
default = None
|
|
var = inner
|
|
if ":-" in inner:
|
|
var, default = inner.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 get_plan_summary() -> tuple[str, Dict]:
|
|
"""Run plan_summarizer and get both formats"""
|
|
# Markdown for comment
|
|
result = subprocess.run(
|
|
["python3", "plan_summarizer.py", "--format", "markdown"],
|
|
cwd=HERE,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
markdown = result.stdout
|
|
|
|
# JSON for processing
|
|
result = subprocess.run(
|
|
["python3", "plan_summarizer.py", "--format", "json"],
|
|
cwd=HERE,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
summary_json = json.loads(result.stdout)
|
|
|
|
return markdown, summary_json
|
|
|
|
|
|
class GitLabCI:
|
|
"""GitLab CI integration"""
|
|
|
|
def __init__(self, token: str):
|
|
self.base_url = os.environ.get("CI_API_V4_URL", "https://gitlab.com/api/v4")
|
|
self.project_id = os.environ.get("CI_PROJECT_ID")
|
|
self.mr_iid = os.environ.get("CI_MERGE_REQUEST_IID")
|
|
self.commit_sha = os.environ.get("CI_COMMIT_SHA", "")[:8]
|
|
self.pipeline_url = os.environ.get("CI_PIPELINE_URL", "")
|
|
self.job_name = os.environ.get("CI_JOB_NAME", "terraform-plan")
|
|
self.token = token
|
|
self.headers = {"PRIVATE-TOKEN": token}
|
|
|
|
@property
|
|
def is_mr_pipeline(self) -> bool:
|
|
return bool(self.mr_iid)
|
|
|
|
def get_existing_comments(self) -> list:
|
|
"""Get existing MR comments"""
|
|
url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes"
|
|
resp = requests.get(url, headers=self.headers)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def find_bot_comment(self, marker: str) -> Optional[Dict]:
|
|
"""Find existing bot comment by marker"""
|
|
comments = self.get_existing_comments()
|
|
for comment in comments:
|
|
if marker in comment.get("body", ""):
|
|
return comment
|
|
return None
|
|
|
|
def post_comment(self, body: str) -> Dict:
|
|
"""Post a new comment on the MR"""
|
|
url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes"
|
|
resp = requests.post(url, headers=self.headers, data={"body": body})
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def update_comment(self, note_id: int, body: str) -> Dict:
|
|
"""Update an existing comment"""
|
|
url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes/{note_id}"
|
|
resp = requests.put(url, headers=self.headers, data={"body": body})
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def delete_comment(self, note_id: int):
|
|
"""Delete a comment"""
|
|
url = f"{self.base_url}/projects/{self.project_id}/merge_requests/{self.mr_iid}/notes/{note_id}"
|
|
resp = requests.delete(url, headers=self.headers)
|
|
resp.raise_for_status()
|
|
|
|
|
|
class GitHubActions:
|
|
"""GitHub Actions integration"""
|
|
|
|
def __init__(self, token: str):
|
|
self.base_url = "https://api.github.com"
|
|
self.repo = os.environ.get("GITHUB_REPOSITORY", "")
|
|
self.pr_number = self._get_pr_number()
|
|
self.commit_sha = os.environ.get("GITHUB_SHA", "")[:8]
|
|
self.run_url = f"https://github.com/{self.repo}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}"
|
|
self.token = token
|
|
self.headers = {
|
|
"Authorization": f"token {token}",
|
|
"Accept": "application/vnd.github.v3+json",
|
|
}
|
|
|
|
def _get_pr_number(self) -> Optional[str]:
|
|
"""Extract PR number from GitHub event"""
|
|
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
|
if event_path and os.path.exists(event_path):
|
|
with open(event_path) as f:
|
|
event = json.load(f)
|
|
pr = event.get("pull_request", {})
|
|
return str(pr.get("number", "")) if pr else None
|
|
return None
|
|
|
|
@property
|
|
def is_pr_pipeline(self) -> bool:
|
|
return bool(self.pr_number)
|
|
|
|
def find_bot_comment(self, marker: str) -> Optional[Dict]:
|
|
"""Find existing bot comment"""
|
|
url = f"{self.base_url}/repos/{self.repo}/issues/{self.pr_number}/comments"
|
|
resp = requests.get(url, headers=self.headers)
|
|
resp.raise_for_status()
|
|
|
|
for comment in resp.json():
|
|
if marker in comment.get("body", ""):
|
|
return comment
|
|
return None
|
|
|
|
def post_comment(self, body: str) -> Dict:
|
|
"""Post a new comment"""
|
|
url = f"{self.base_url}/repos/{self.repo}/issues/{self.pr_number}/comments"
|
|
resp = requests.post(url, headers=self.headers, json={"body": body})
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def update_comment(self, comment_id: int, body: str) -> Dict:
|
|
"""Update existing comment"""
|
|
url = f"{self.base_url}/repos/{self.repo}/issues/comments/{comment_id}"
|
|
resp = requests.patch(url, headers=self.headers, json={"body": body})
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def build_comment_body(
|
|
cfg: Dict[str, Any],
|
|
summary_md: str,
|
|
summary_json: Dict,
|
|
ci_info: Dict,
|
|
) -> str:
|
|
"""Build the full comment body"""
|
|
ci_cfg = cfg.get("ci", {})
|
|
header = ci_cfg.get("comment_header", "Terraform Plan Summary")
|
|
|
|
# Risk indicator
|
|
risk = summary_json.get("overall_risk", "UNKNOWN")
|
|
risk_emoji = {
|
|
"LOW": "🟢",
|
|
"MEDIUM": "🟡",
|
|
"HIGH": "🟠",
|
|
"CRITICAL": "🔴",
|
|
}.get(risk, "⚪")
|
|
|
|
# Marker for finding/updating this comment
|
|
marker = "<!-- gitops-plan-comment -->"
|
|
|
|
changes = summary_json.get("total_changes", 0)
|
|
compliance = summary_json.get("compliance_violations", [])
|
|
|
|
# Build body
|
|
lines = [
|
|
marker,
|
|
f"# {risk_emoji} {header}",
|
|
"",
|
|
f"**Commit:** `{ci_info.get('commit_sha', 'N/A')}`",
|
|
f"**Pipeline:** [{ci_info.get('job_name', 'terraform-plan')}]({ci_info.get('pipeline_url', '#')})",
|
|
"",
|
|
]
|
|
|
|
# Compliance warning banner
|
|
if compliance:
|
|
frameworks = ", ".join(compliance)
|
|
lines.extend([
|
|
f"> ⚠️ **Compliance Impact:** {frameworks}",
|
|
"",
|
|
])
|
|
|
|
# No changes case
|
|
if changes == 0:
|
|
lines.extend([
|
|
"✅ **No changes detected.**",
|
|
"",
|
|
"Terraform state matches the current configuration.",
|
|
])
|
|
else:
|
|
# Add summary
|
|
lines.append(summary_md)
|
|
|
|
# Add approval reminder for high risk
|
|
if risk in ("HIGH", "CRITICAL"):
|
|
lines.extend([
|
|
"",
|
|
"---",
|
|
f"⚠️ **{risk} risk changes detected.** Additional review recommended.",
|
|
])
|
|
|
|
lines.extend([
|
|
"",
|
|
"---",
|
|
f"*Last updated: {ci_info.get('timestamp', 'N/A')} • Phase 6 GitOps*",
|
|
])
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
"""Main entry point"""
|
|
import argparse
|
|
from datetime import datetime
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Post terraform plan comment on MR"
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print comment but don't post",
|
|
)
|
|
parser.add_argument(
|
|
"--update",
|
|
action="store_true",
|
|
default=True,
|
|
help="Update existing comment instead of creating new one",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Load config
|
|
cfg = load_config()
|
|
|
|
# Detect CI platform
|
|
token = os.environ.get("GITLAB_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
if not token:
|
|
print("ERROR: GITLAB_TOKEN or GITHUB_TOKEN required", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Determine platform
|
|
if os.environ.get("GITLAB_CI"):
|
|
ci = GitLabCI(token)
|
|
platform = "gitlab"
|
|
elif os.environ.get("GITHUB_ACTIONS"):
|
|
ci = GitHubActions(token)
|
|
platform = "github"
|
|
else:
|
|
print("ERROR: Must run in GitLab CI or GitHub Actions", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Check if this is an MR/PR pipeline
|
|
if not ci.is_mr_pipeline and not ci.is_pr_pipeline:
|
|
print("Not an MR/PR pipeline. Skipping comment.")
|
|
return
|
|
|
|
# Get plan summary
|
|
print("Getting plan summary...")
|
|
summary_md, summary_json = get_plan_summary()
|
|
|
|
# Build CI info
|
|
ci_info = {
|
|
"commit_sha": getattr(ci, "commit_sha", ""),
|
|
"pipeline_url": getattr(ci, "pipeline_url", "") or getattr(ci, "run_url", ""),
|
|
"job_name": getattr(ci, "job_name", "terraform-plan"),
|
|
"timestamp": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
}
|
|
|
|
# Build comment
|
|
body = build_comment_body(cfg, summary_md, summary_json, ci_info)
|
|
|
|
if args.dry_run:
|
|
print("\n" + "=" * 60)
|
|
print("[DRY RUN] Would post comment:")
|
|
print("=" * 60)
|
|
print(body)
|
|
return
|
|
|
|
# Find existing comment to update
|
|
marker = "<!-- gitops-plan-comment -->"
|
|
existing = ci.find_bot_comment(marker)
|
|
|
|
if existing and args.update:
|
|
print(f"Updating existing comment {existing.get('id') or existing.get('note_id')}...")
|
|
note_id = existing.get("id") or existing.get("note_id")
|
|
ci.update_comment(note_id, body)
|
|
print("Comment updated.")
|
|
else:
|
|
print("Posting new comment...")
|
|
result = ci.post_comment(body)
|
|
print(f"Comment posted: {result.get('id') or result.get('html_url')}")
|
|
|
|
# Output for CI
|
|
risk = summary_json.get("overall_risk", "UNKNOWN")
|
|
changes = summary_json.get("total_changes", 0)
|
|
|
|
print(f"\nSummary: {changes} changes, {risk} risk")
|
|
|
|
# Set CI output variables (for use in subsequent jobs)
|
|
if os.environ.get("GITHUB_OUTPUT"):
|
|
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
|
f.write(f"risk_level={risk}\n")
|
|
f.write(f"change_count={changes}\n")
|
|
elif os.environ.get("GITLAB_CI"):
|
|
# GitLab: write to dotenv artifact
|
|
with open("plan_output.env", "w") as f:
|
|
f.write(f"PLAN_RISK_LEVEL={risk}\n")
|
|
f.write(f"PLAN_CHANGE_COUNT={changes}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|