Skip to content

Commit e32d84d

Browse files
committed
Updating new version of devolv
1 parent 6699415 commit e32d84d

5 files changed

Lines changed: 86 additions & 57 deletions

File tree

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.11"
1+
__version__ = "0.2.12"
22

devolv/drift/aws_fetcher.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import boto3
2-
2+
import json
3+
34
def get_aws_policy_document(policy_arn: str) -> dict:
45
"""
56
Fetch the JSON document of the default version of a managed IAM policy.
@@ -23,3 +24,24 @@ def merge_policy_documents(local_doc: dict, aws_doc: dict) -> dict:
2324
merged.append(stmt)
2425
aws_doc["Statement"] = merged
2526
return aws_doc
27+
28+
def build_superset_policy(local_doc: dict, aws_doc: dict) -> dict:
29+
"""
30+
Combine local and AWS policy documents into a superset without duplicate statements.
31+
"""
32+
local_statements = local_doc.get("Statement", [])
33+
aws_statements = aws_doc.get("Statement", [])
34+
35+
seen = set()
36+
combined = []
37+
38+
for stmt in local_statements + aws_statements:
39+
stmt_str = json.dumps(stmt, sort_keys=True)
40+
if stmt_str not in seen:
41+
seen.add(stmt_str)
42+
combined.append(stmt)
43+
44+
return {
45+
"Version": "2012-10-17",
46+
"Statement": combined
47+
}

devolv/drift/cli.py

Lines changed: 41 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,20 @@
44
import os
55
import subprocess
66

7-
from devolv.drift.aws_fetcher import get_aws_policy_document, merge_policy_documents
7+
from devolv.drift.aws_fetcher import get_aws_policy_document, merge_policy_documents, build_superset_policy
88
from devolv.drift.issues import create_approval_issue, wait_for_sync_choice
99
from devolv.drift.github_approvals import create_github_pr
1010
from devolv.drift.report import detect_and_print_drift
1111

1212
app = typer.Typer()
1313

1414
def push_branch(branch_name: str):
15-
"""
16-
Create and push a branch with committed changes.
17-
"""
1815
try:
1916
subprocess.run(["git", "checkout", "-b", branch_name], check=True)
20-
21-
# ✅ Ensure Git identity is set (important for CI runners)
2217
subprocess.run(["git", "config", "user.email", "github-actions@users.noreply.github.com"], check=True)
2318
subprocess.run(["git", "config", "user.name", "github-actions"], check=True)
24-
2519
subprocess.run(["git", "add", "."], check=True)
26-
subprocess.run(["git", "commit", "-m", f"Update policy from AWS: {branch_name}"], check=True)
20+
subprocess.run(["git", "commit", "-m", f"Update policy: {branch_name}"], check=True)
2721
subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True)
2822
typer.echo(f"✅ Pushed branch {branch_name} to origin.")
2923
except subprocess.CalledProcessError as e:
@@ -35,18 +29,12 @@ def drift(
3529
policy_name: str = typer.Option(..., "--policy-name", help="Name of the IAM policy"),
3630
policy_file: str = typer.Option(..., "--file", help="Path to local policy file"),
3731
account_id: str = typer.Option(None, "--account-id", help="AWS Account ID (optional, auto-detected if not provided)"),
38-
approvers: str = typer.Option("", help="Comma-separated GitHub usernames for approval"),
32+
approvers: str = typer.Option("", help="Comma-separated GitHub usernames for approval (optional)"),
3933
approval_anyway: bool = typer.Option(False, "--approval-anyway", help="Request approval even if no drift"),
4034
repo_full_name: str = typer.Option(None, "--repo", help="GitHub repo full name (e.g., org/repo)")
4135
):
42-
"""
43-
Detect drift between local policy (file) and AWS policy (ARN),
44-
create GitHub issue for approval, and perform sync based on comment.
45-
"""
4636
if not account_id:
47-
sts = boto3.client("sts")
48-
account_id = sts.get_caller_identity()["Account"]
49-
37+
account_id = boto3.client("sts").get_caller_identity()["Account"]
5038
policy_arn = f"arn:aws:iam::{account_id}:policy/{policy_name}"
5139

5240
try:
@@ -63,9 +51,7 @@ def drift(
6351
typer.echo("✅ No drift detected. Use --approval-anyway to force approval.")
6452
raise typer.Exit()
6553

66-
if not repo_full_name:
67-
repo_full_name = os.getenv("GITHUB_REPOSITORY")
68-
54+
repo_full_name = repo_full_name or os.getenv("GITHUB_REPOSITORY")
6955
if not repo_full_name:
7056
typer.echo("❌ GitHub repo not specified. Use --repo or set GITHUB_REPOSITORY.")
7157
raise typer.Exit(1)
@@ -75,38 +61,51 @@ def drift(
7561
typer.echo("❌ GITHUB_TOKEN not set in environment.")
7662
raise typer.Exit(1)
7763

78-
issue_num = create_approval_issue(repo_full_name, token, policy_name)
79-
typer.echo(f"Issue #{issue_num} created for approval.")
64+
assignees = [a.strip() for a in approvers.split(",") if a.strip()]
65+
issue_num, _ = create_approval_issue(repo_full_name, token, policy_name, assignees=assignees)
66+
typer.echo(f"✅ Created issue #{issue_num} for approval.")
8067

8168
choice = wait_for_sync_choice(repo_full_name, issue_num, token)
69+
iam = boto3.client("iam")
8270

8371
if choice == "local->aws":
8472
merged_doc = merge_policy_documents(local_doc, aws_doc)
85-
iam = boto3.client("iam")
86-
versions = iam.list_policy_versions(PolicyArn=policy_arn)['Versions']
87-
if len(versions) >= 5:
88-
oldest = sorted((v for v in versions if not v['IsDefaultVersion']),
89-
key=lambda v: v['CreateDate'])[0]
90-
iam.delete_policy_version(PolicyArn=policy_arn, VersionId=oldest['VersionId'])
91-
iam.create_policy_version(
92-
PolicyArn=policy_arn,
93-
PolicyDocument=json.dumps(merged_doc),
94-
SetAsDefault=True
95-
)
73+
_update_aws_policy(iam, policy_arn, merged_doc)
9674
typer.echo(f"✅ AWS policy {policy_arn} updated with local changes (append-only).")
9775

9876
elif choice == "aws->local":
99-
new_content = json.dumps(aws_doc, indent=2)
100-
with open(policy_file, "w") as f:
101-
f.write(new_content)
102-
103-
branch = f"update-policy-{policy_name}"
104-
pr_title = f"Update {policy_file} from AWS policy"
105-
pr_body = "This PR updates the local policy file with the AWS default version."
77+
_update_local_and_create_pr(aws_doc, policy_file, repo_full_name, policy_name, issue_num, description="from AWS policy")
10678

107-
push_branch(branch)
108-
pr_num = create_github_pr(repo_full_name, branch, pr_title, pr_body)
109-
typer.echo(f"✅ Created PR #{pr_num}: updated {policy_file} from AWS policy.")
79+
elif choice == "aws<->local":
80+
superset_doc = build_superset_policy(local_doc, aws_doc)
81+
_update_aws_policy(iam, policy_arn, superset_doc)
82+
typer.echo(f"✅ AWS policy {policy_arn} updated with superset of local + AWS.")
83+
_update_local_and_create_pr(superset_doc, policy_file, repo_full_name, policy_name, issue_num, description="with superset of local + AWS")
11084

11185
else:
11286
typer.echo("⏭ No synchronization performed (skip).")
87+
88+
def _update_aws_policy(iam, policy_arn, policy_doc):
89+
versions = iam.list_policy_versions(PolicyArn=policy_arn)['Versions']
90+
if len(versions) >= 5:
91+
oldest = sorted((v for v in versions if not v['IsDefaultVersion']), key=lambda v: v['CreateDate'])[0]
92+
iam.delete_policy_version(PolicyArn=policy_arn, VersionId=oldest['VersionId'])
93+
iam.create_policy_version(
94+
PolicyArn=policy_arn,
95+
PolicyDocument=json.dumps(policy_doc),
96+
SetAsDefault=True
97+
)
98+
99+
def _update_local_and_create_pr(doc, policy_file, repo_full_name, policy_name, issue_num, description=""):
100+
new_content = json.dumps(doc, indent=2)
101+
with open(policy_file, "w") as f:
102+
f.write(new_content)
103+
104+
branch = f"{description.replace(' ', '-')}-policy-{policy_name}".strip("-")
105+
push_branch(branch)
106+
107+
pr_title = f"Update {policy_file} {description}".strip()
108+
pr_body = f"This PR updates `{policy_file}` {description}.\n\nLinked to issue #{issue_num}.".strip()
109+
pr_num, pr_url = create_github_pr(repo_full_name, branch, pr_title, pr_body, issue_num=issue_num)
110+
111+
typer.echo(f"✅ Created PR #{pr_num}: {pr_url}")

devolv/drift/github_approvals.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import subprocess
33
from github import Github
44
import typer
5+
56
def _get_github_token():
67
token = os.getenv("GITHUB_TOKEN")
78
if not token:
@@ -16,22 +17,23 @@ def _get_github_repo(repo_full_name: str):
1617
gh = Github(_get_github_token())
1718
return gh.get_repo(repo_full_name)
1819

19-
def create_github_issue(repo: str, title: str, body: str, assignees: list) -> int:
20+
def create_github_issue(repo: str, title: str, body: str, assignees: list) -> tuple:
2021
"""
21-
Create a GitHub issue using the GitHub API.
22+
Create a GitHub issue and return (number, url)
2223
"""
2324
try:
2425
repo_obj = _get_github_repo(repo)
2526
issue = repo_obj.create_issue(title=title, body=body, assignees=assignees)
26-
print(f"✅ Created issue #{issue.number} in {repo}")
27-
return issue.number
27+
print(f"✅ Created issue #{issue.number} in {repo}: {issue.html_url}")
28+
return issue.number, issue.html_url
2829
except Exception as e:
2930
print(f"❌ Failed to create issue in {repo}: {e}")
3031
raise
3132

32-
def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: str = "main") -> int:
33+
def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: str = "main", issue_num: int = None) -> tuple:
3334
"""
34-
Create a GitHub pull request using the GitHub API.
35+
Create a GitHub PR. If issue_num is provided, comment on the issue and close it.
36+
Return (PR number, PR URL).
3537
"""
3638
try:
3739
repo_obj = _get_github_repo(repo)
@@ -41,23 +43,29 @@ def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: s
4143
head=head_branch,
4244
base=base
4345
)
44-
print(f"✅ Created PR #{pr.number} in {repo}")
45-
return pr.number
46+
print(f"✅ Created PR #{pr.number} in {repo}: {pr.html_url}")
47+
48+
if issue_num:
49+
issue = repo_obj.get_issue(number=issue_num)
50+
issue.create_comment(f"A PR has been created for this sync: {pr.html_url}")
51+
issue.edit(state="closed")
52+
print(f"💬 Commented on and closed issue #{issue_num}.")
53+
54+
return pr.number, pr.html_url
55+
4656
except Exception as e:
4757
print(f"❌ Failed to create PR in {repo}: {e}")
4858
raise
4959

60+
5061
def push_branch(branch_name: str):
5162
"""
5263
Create and push a branch with committed changes.
5364
"""
5465
try:
5566
subprocess.run(["git", "checkout", "-b", branch_name], check=True)
56-
57-
# Configure git user identity locally
5867
subprocess.run(["git", "config", "user.email", "github-actions@users.noreply.github.com"], check=True)
5968
subprocess.run(["git", "config", "user.name", "github-actions"], check=True)
60-
6169
subprocess.run(["git", "add", "."], check=True)
6270
subprocess.run(["git", "commit", "-m", f"Update policy from AWS: {branch_name}"], check=True)
6371
subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True)
@@ -66,4 +74,3 @@ def push_branch(branch_name: str):
6674
except subprocess.CalledProcessError as e:
6775
typer.echo(f"❌ Git command failed: {e}")
6876
raise typer.Exit(1)
69-

devolv/drift/issues.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def create_approval_issue(repo_full_name, token, policy_name):
99
"Please comment:\n"
1010
"- `local->aws` to sync local changes to AWS\n"
1111
"- `aws->local` to sync AWS changes to local file\n"
12+
"- `aws<->local` to sync both ways (first AWS -> local, then local -> AWS)\n"
1213
"- `skip` to do nothing"
1314
)
1415
issue = repo.create_issue(
@@ -26,7 +27,7 @@ def wait_for_sync_choice(repo_full_name, issue_number, token):
2627
comments = issue.get_comments()
2728
for comment in comments:
2829
content = comment.body.strip().lower()
29-
if content in ["local->aws", "aws->local", "skip"]:
30+
if content in ["local->aws", "aws->local", "aws<->local" "skip"]:
3031
return content
3132
print("Waiting for approval comment...")
3233
time.sleep(30) # Poll every 30 seconds

0 commit comments

Comments
 (0)