Skip to content

Commit 65bfce0

Browse files
committed
adding new version in wrapper
1 parent ef5a7fb commit 65bfce0

File tree

3 files changed

+154
-69
lines changed

3 files changed

+154
-69
lines changed

devolv/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = "0.2.29"
1+
__version__ = "0.2.30"
22

devolv/drift/cli.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,15 @@ def push_branch(branch_name: str):
4747

4848

4949
def detect_drift(local_doc, aws_doc) -> bool:
50-
local_statements = {json.dumps(s, sort_keys=True) for s in local_doc.get("Statement", [])}
51-
aws_statements = {json.dumps(s, sort_keys=True) for s in aws_doc.get("Statement", [])}
52-
53-
missing_in_local = aws_statements - local_statements
54-
55-
if missing_in_local:
56-
typer.echo("❌ Drift detected: Local is missing permissions present in AWS.")
50+
from deepdiff import DeepDiff
51+
diff = DeepDiff(aws_doc, local_doc, ignore_order=True)
52+
if diff:
53+
typer.echo(f"❌ Drift detected:\n{json.dumps(diff, indent=2)}")
5754
return True
58-
59-
typer.echo("✅ No removal drift detected (local may have extra permissions; that's fine).")
55+
typer.echo("✅ No drift detected.")
6056
return False
6157

58+
6259
@app.command()
6360
def drift(
6461
dummy: str = typer.Argument(None, hidden=True),

devolv/drift/report.py

Lines changed: 147 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,163 @@
11
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
55
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
1514

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()
2016

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)
2224

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)
2731

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.")
3033

34+
except subprocess.CalledProcessError as e:
35+
typer.echo(f"❌ Git command failed: {e}")
36+
raise typer.Exit(1)
3137

32-
def generate_diff_lines(local_doc: dict, aws_doc: dict):
38+
def detect_drift(local_doc, aws_doc) -> dict:
3339
"""
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.
3542
"""
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.")
4949
return diff
5050

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)
5771

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.")
6177
return
6278

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)
75158

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

Comments
 (0)