|
1 | 1 | import json |
2 | | -import difflib |
3 | | -from rich.console import Console |
4 | | -from rich.text import Text |
| 2 | +import os |
| 3 | +import subprocess |
| 4 | +import boto3 |
5 | 5 | import typer |
6 | | -def clean_policy(policy): |
7 | | - """ |
8 | | - Remove empty statements ({} entries) from the policy's 'Statement' list. |
9 | | - """ |
10 | | - if isinstance(policy, dict) and "Statement" in policy: |
11 | | - statements = policy.get("Statement", []) |
12 | | - if isinstance(statements, list): |
13 | | - policy["Statement"] = [s for s in statements if s] |
14 | | - return policy |
| 6 | +from github import Github |
| 7 | +from datetime import datetime |
| 8 | +from deepdiff import DeepDiff |
| 9 | + |
| 10 | +from devolv.drift.aws_fetcher import get_aws_policy_document, merge_policy_documents, build_superset_policy |
| 11 | +from devolv.drift.issues import create_approval_issue, wait_for_sync_choice |
| 12 | +from devolv.drift.github_approvals import create_github_pr |
| 13 | +from devolv.drift.report import print_drift_diff |
15 | 14 |
|
16 | | -def detect_drift(local_doc, aws_doc) -> bool: |
17 | | - """Detect removal drift: AWS has permissions missing from local (danger).""" |
18 | | - local_statements = {json.dumps(s, sort_keys=True) for s in local_doc.get("Statement", [])} |
19 | | - aws_statements = {json.dumps(s, sort_keys=True) for s in aws_doc.get("Statement", [])} |
| 15 | +app = typer.Typer() |
20 | 16 |
|
21 | | - missing_in_local = aws_statements - local_statements |
| 17 | +def push_branch(branch_name: str): |
| 18 | + try: |
| 19 | + subprocess.run(["git", "checkout", "-B", branch_name], check=True) |
| 20 | + subprocess.run(["git", "config", "user.email", "github-actions@users.noreply.github.com"], check=True) |
| 21 | + subprocess.run(["git", "config", "user.name", "github-actions"], check=True) |
| 22 | + subprocess.run(["git", "add", "."], check=True) |
| 23 | + subprocess.run(["git", "commit", "-m", f"Update policy: {branch_name}"], check=True) |
22 | 24 |
|
23 | | - if missing_in_local: |
24 | | - typer.echo("❌ Drift detected: Local is missing permissions present in AWS.") |
25 | | - # No need to print each JSON line — rich diff will handle details |
26 | | - return True |
| 25 | + try: |
| 26 | + subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True) |
| 27 | + except subprocess.CalledProcessError: |
| 28 | + typer.echo("⚠️ Initial push failed. Attempting rebase + push...") |
| 29 | + subprocess.run(["git", "pull", "--rebase", "origin", branch_name], check=True) |
| 30 | + subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True) |
27 | 31 |
|
28 | | - typer.echo("✅ No removal drift detected (local may have extra permissions; that's fine).") |
29 | | - return False |
| 32 | + typer.echo(f"✅ Pushed branch {branch_name} to origin.") |
30 | 33 |
|
| 34 | + except subprocess.CalledProcessError as e: |
| 35 | + typer.echo(f"❌ Git command failed: {e}") |
| 36 | + raise typer.Exit(1) |
31 | 37 |
|
32 | | -def generate_diff_lines(local_doc: dict, aws_doc: dict): |
| 38 | +def detect_drift(local_doc, aws_doc) -> dict: |
33 | 39 | """ |
34 | | - Generate a unified diff between local and AWS policy JSONs. |
| 40 | + Use deepdiff to detect all drift between local and AWS policy documents. |
| 41 | + Return the diff dict. |
35 | 42 | """ |
36 | | - local_str = json.dumps(clean_policy(local_doc), indent=2, sort_keys=True) |
37 | | - aws_str = json.dumps(clean_policy(aws_doc), indent=2, sort_keys=True) |
38 | | - |
39 | | - diff = list(difflib.unified_diff( |
40 | | - local_str.splitlines(), |
41 | | - aws_str.splitlines(), |
42 | | - fromfile="local", |
43 | | - tofile="aws", |
44 | | - lineterm="" |
45 | | - )) |
46 | | - if not diff: |
47 | | - # Return at least header lines if no differences |
48 | | - return ["--- local", "+++ aws"] |
| 43 | + diff = DeepDiff(aws_doc, local_doc, ignore_order=True) |
| 44 | + if diff: |
| 45 | + typer.echo("❌ Drift detected:") |
| 46 | + typer.echo(json.dumps(diff, indent=2)) |
| 47 | + else: |
| 48 | + typer.echo("✅ No drift detected.") |
49 | 49 | return diff |
50 | 50 |
|
51 | | -def print_drift_diff(local_doc: dict, aws_doc: dict): |
52 | | - """ |
53 | | - Pretty-print a unified diff using Rich. |
54 | | - """ |
55 | | - console = Console() |
56 | | - diff_lines = generate_diff_lines(local_doc, aws_doc) |
| 51 | +@app.command() |
| 52 | +def drift( |
| 53 | + policy_name: str = typer.Option(..., "--policy-name", help="Name of the IAM policy"), |
| 54 | + policy_file: str = typer.Option(..., "--file", help="Path to local policy file"), |
| 55 | + account_id: str = typer.Option(None, "--account-id", help="AWS Account ID (optional)"), |
| 56 | + approvers: str = typer.Option("", help="Comma-separated GitHub usernames for approval (optional)"), |
| 57 | + approval_anyway: bool = typer.Option(False, "--approval-anyway", help="Request approval even if no drift"), |
| 58 | + repo_full_name: str = typer.Option(None, "--repo", help="GitHub repo full name (e.g., org/repo)") |
| 59 | +): |
| 60 | + iam = boto3.client("iam") |
| 61 | + if not account_id: |
| 62 | + account_id = boto3.client("sts").get_caller_identity()["Account"] |
| 63 | + policy_arn = f"arn:aws:iam::{account_id}:policy/{policy_name}" |
| 64 | + |
| 65 | + try: |
| 66 | + with open(policy_file) as f: |
| 67 | + local_doc = json.load(f) |
| 68 | + except FileNotFoundError: |
| 69 | + typer.echo(f"❌ Local policy file {policy_file} not found.") |
| 70 | + raise typer.Exit(1) |
57 | 71 |
|
58 | | - # If only headers (or no lines), treat as no drift |
59 | | - if not diff_lines or (len(diff_lines) == 2 and diff_lines[0].startswith("---") and diff_lines[1].startswith("+++")): |
60 | | - console.print("✅ No drift detected: Policies match.", style="green") |
| 72 | + aws_doc = get_aws_policy_document(policy_arn) |
| 73 | + diff = detect_drift(local_doc, aws_doc) |
| 74 | + |
| 75 | + if not diff and not approval_anyway: |
| 76 | + typer.echo("✅ No drift and no forced approval requested. Exiting.") |
61 | 77 | return |
62 | 78 |
|
63 | | - console.print("❌ Drift detected — see diff below", style="bold red") |
64 | | - for line in diff_lines: |
65 | | - if line.startswith('---') or line.startswith('+++'): |
66 | | - console.print(Text(line, style="bold")) |
67 | | - elif line.startswith('@@'): |
68 | | - console.print(Text(line, style="cyan")) |
69 | | - elif line.startswith('-'): |
70 | | - console.print(Text(line, style="red")) |
71 | | - elif line.startswith('+'): |
72 | | - console.print(Text(line, style="green")) |
73 | | - else: |
74 | | - console.print(Text(line, style="bright_black")) |
| 79 | + # print rich diff if drift exists |
| 80 | + if diff: |
| 81 | + print_drift_diff(local_doc, aws_doc) |
| 82 | + |
| 83 | + # Approval flow starts |
| 84 | + repo_full_name = repo_full_name or os.getenv("GITHUB_REPOSITORY") |
| 85 | + token = os.getenv("GITHUB_TOKEN") |
| 86 | + |
| 87 | + if not repo_full_name: |
| 88 | + typer.echo("❌ GitHub repo not specified. Use --repo or set GITHUB_REPOSITORY.") |
| 89 | + raise typer.Exit(1) |
| 90 | + if not token: |
| 91 | + typer.echo("❌ GITHUB_TOKEN not set in environment.") |
| 92 | + raise typer.Exit(1) |
| 93 | + |
| 94 | + assignees = [a.strip() for a in approvers.split(",") if a.strip()] |
| 95 | + issue_num, _ = create_approval_issue(repo_full_name, token, policy_name, assignees=assignees, drift_detected=bool(diff)) |
| 96 | + typer.echo(f"✅ Approval issue created: https://github.com/{repo_full_name}/issues/{issue_num}") |
| 97 | + |
| 98 | + choice = wait_for_sync_choice(repo_full_name, issue_num, token) |
| 99 | + |
| 100 | + if choice == "local->aws": |
| 101 | + merged_doc = merge_policy_documents(local_doc, aws_doc) |
| 102 | + _update_aws_policy(iam, policy_arn, merged_doc) |
| 103 | + typer.echo(f"✅ AWS policy {policy_arn} updated with local changes (append-only).") |
| 104 | + |
| 105 | + elif choice == "aws->local": |
| 106 | + _update_local_and_create_pr(aws_doc, policy_file, repo_full_name, policy_name, issue_num, token, "from AWS policy") |
| 107 | + |
| 108 | + elif choice == "aws<->local": |
| 109 | + superset_doc = build_superset_policy(local_doc, aws_doc) |
| 110 | + _update_aws_policy(iam, policy_arn, superset_doc) |
| 111 | + typer.echo(f"✅ AWS policy {policy_arn} updated with superset of local + AWS.") |
| 112 | + _update_local_and_create_pr(superset_doc, policy_file, repo_full_name, policy_name, issue_num, token, "with superset of local + AWS") |
| 113 | + |
| 114 | + elif choice == "accept": |
| 115 | + gh = Github(token) |
| 116 | + repo = gh.get_repo(repo_full_name) |
| 117 | + issue = repo.get_issue(number=issue_num) |
| 118 | + issue.create_comment("✅ Approved current state. No action needed.") |
| 119 | + issue.edit(state="closed") |
| 120 | + typer.echo("✅ Approval received. No action required. Issue closed.") |
| 121 | + |
| 122 | + elif choice == "reject": |
| 123 | + gh = Github(token) |
| 124 | + repo = gh.get_repo(repo_full_name) |
| 125 | + issue = repo.get_issue(number=issue_num) |
| 126 | + issue.create_comment("❌ Rejected current state. No action taken.") |
| 127 | + issue.edit(state="closed") |
| 128 | + typer.echo("❌ Approval rejected. Issue closed.") |
| 129 | + |
| 130 | + else: |
| 131 | + typer.echo("⏭ No synchronization performed (skip).") |
| 132 | + |
| 133 | +def _update_aws_policy(iam, policy_arn, policy_doc): |
| 134 | + versions = iam.list_policy_versions(PolicyArn=policy_arn)["Versions"] |
| 135 | + if len(versions) >= 5: |
| 136 | + oldest = sorted((v for v in versions if not v["IsDefaultVersion"]), key=lambda v: v["CreateDate"])[0] |
| 137 | + iam.delete_policy_version(PolicyArn=policy_arn, VersionId=oldest["VersionId"]) |
| 138 | + iam.create_policy_version( |
| 139 | + PolicyArn=policy_arn, |
| 140 | + PolicyDocument=json.dumps(policy_doc), |
| 141 | + SetAsDefault=True |
| 142 | + ) |
| 143 | + |
| 144 | +def _update_local_and_create_pr(doc, policy_file, repo_full_name, policy_name, issue_num, token, description=""): |
| 145 | + new_content = json.dumps(doc, indent=2) |
| 146 | + with open(policy_file, "w") as f: |
| 147 | + f.write(new_content) |
| 148 | + |
| 149 | + timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") |
| 150 | + branch = f"drift-sync-{policy_name}-{timestamp}".replace("/", "-").lower() |
| 151 | + |
| 152 | + push_branch(branch) |
| 153 | + |
| 154 | + pr_title = f"Update {policy_file} {description}".strip() |
| 155 | + pr_body = f"This PR updates `{policy_file}` {description}.\n\nLinked to issue #{issue_num}.".strip() |
| 156 | + |
| 157 | + pr_num, pr_url = create_github_pr(repo_full_name, branch, pr_title, pr_body, issue_num=issue_num) |
75 | 158 |
|
| 159 | + gh = Github(token) |
| 160 | + repo = gh.get_repo(repo_full_name) |
| 161 | + issue = repo.get_issue(number=issue_num) |
| 162 | + issue.create_comment(f"✅ PR created and linked: {pr_url}. Closing issue.") |
| 163 | + issue.edit(state="closed") |
0 commit comments