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:
358
gitops/ci_plan_comment.py
Normal file
358
gitops/ci_plan_comment.py
Normal file
@@ -0,0 +1,358 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user