Skip to content

Commit 9c20efd

Browse files
committed
enhancing error handeling
1 parent 6fd2431 commit 9c20efd

5 files changed

Lines changed: 136 additions & 145 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.31"
1+
__version__ = "0.2.39"
22

devolv/drift/cli.py

Lines changed: 62 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
)
1414
from devolv.drift.issues import create_approval_issue, wait_for_sync_choice
1515
from devolv.drift.github_approvals import create_github_pr
16-
from devolv.drift.report import print_drift_diff
16+
from devolv.drift.report import print_drift_diff, normalize_statement
1717

1818
app = typer.Typer()
1919

@@ -24,40 +24,43 @@ def push_branch(branch_name: str):
2424
subprocess.run(["git", "config", "user.name", "github-actions"], check=True)
2525
subprocess.run(["git", "add", "."], check=True)
2626
subprocess.run(["git", "commit", "-m", f"Update policy: {branch_name}"], check=True)
27-
28-
try:
29-
subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True)
30-
except subprocess.CalledProcessError:
31-
typer.echo("⚠️ Initial push failed. Attempting rebase + push...")
32-
subprocess.run(["git", "pull", "--rebase", "origin", branch_name], check=True)
33-
subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True)
34-
27+
subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True)
3528
typer.echo(f"✅ Pushed branch {branch_name} to origin.")
3629
except subprocess.CalledProcessError as e:
3730
typer.echo(f"❌ Git command failed: {e}")
3831
raise typer.Exit(1)
3932

40-
def detect_drift(local_doc, aws_doc) -> bool:
41-
local_statements = {json.dumps(s, sort_keys=True) for s in local_doc.get("Statement", [])}
42-
aws_statements = {json.dumps(s, sort_keys=True) for s in aws_doc.get("Statement", [])}
33+
def close_issue(repo_full_name, token, issue_num, comment):
34+
gh = Github(token)
35+
repo = gh.get_repo(repo_full_name)
36+
issue = repo.get_issue(number=issue_num)
37+
issue.create_comment(comment)
38+
issue.edit(state="closed")
4339

40+
def detect_drift(local_doc, aws_doc) -> bool:
41+
local_statements = {
42+
json.dumps(normalize_statement(s), sort_keys=True)
43+
for s in local_doc.get("Statement", [])
44+
}
45+
aws_statements = {
46+
json.dumps(normalize_statement(s), sort_keys=True)
47+
for s in aws_doc.get("Statement", [])
48+
}
4449
missing_in_local = aws_statements - local_statements
45-
4650
if missing_in_local:
4751
typer.echo("❌ Drift detected: Local is missing permissions present in AWS.")
4852
return True
49-
5053
typer.echo("✅ No removal drift detected (local may have extra permissions; that's fine).")
5154
return False
5255

5356
@app.command()
5457
def drift(
55-
policy_name: str = typer.Option(..., "--policy-name", help="Name of the IAM policy"),
56-
policy_file: str = typer.Option(..., "--file", help="Path to local policy file"),
57-
account_id: str = typer.Option(None, "--account-id", help="AWS Account ID (optional)"),
58+
policy_name: str = typer.Option(..., "--policy-name"),
59+
policy_file: str = typer.Option(..., "--file"),
60+
account_id: str = typer.Option(None, "--account-id"),
5861
approvers: str = typer.Option("", help="Comma-separated GitHub usernames for approval"),
59-
approval_anyway: bool = typer.Option(False, "--approval-anyway", help="Request approval even if no drift"),
60-
repo_full_name: str = typer.Option(None, "--repo", help="GitHub repo full name (org/repo)")
62+
approval_anyway: bool = typer.Option(False, "--approval-anyway"),
63+
repo_full_name: str = typer.Option(None, "--repo")
6164
):
6265
iam = boto3.client("iam")
6366
if not account_id:
@@ -74,79 +77,71 @@ def drift(
7477
aws_doc = get_aws_policy_document(policy_arn)
7578
drift_detected = detect_drift(local_doc, aws_doc)
7679

77-
if drift_detected:
78-
print_drift_diff(local_doc, aws_doc)
79-
80-
if not drift_detected:
81-
try:
82-
_update_aws_policy(iam, policy_arn, local_doc)
83-
except ValueError as ve:
84-
typer.echo(str(ve))
85-
raise typer.Exit(1)
86-
typer.echo(f"✅ AWS policy {policy_arn} updated to include any local additions.")
87-
if not approval_anyway:
88-
typer.echo("✅ No forced approval requested. Exiting.")
89-
return
90-
9180
repo_full_name = repo_full_name or os.getenv("GITHUB_REPOSITORY")
9281
token = os.getenv("GITHUB_TOKEN")
93-
9482
if not repo_full_name:
95-
typer.echo("❌ GitHub repo not specified. Use --repo or set GITHUB_REPOSITORY.")
83+
typer.echo("❌ GitHub repo not specified.")
9684
raise typer.Exit(1)
9785
if not token:
98-
typer.echo("❌ GITHUB_TOKEN not set in environment.")
86+
typer.echo("❌ GITHUB_TOKEN not set.")
9987
raise typer.Exit(1)
100-
10188
assignees = [a.strip() for a in approvers.split(",") if a.strip()]
102-
issue_num, _ = create_approval_issue(repo_full_name, token, policy_name, assignees=assignees)
103-
issue_url = f"https://github.com/{repo_full_name}/issues/{issue_num}"
104-
typer.echo(f"✅ Approval issue created: {issue_url}")
10589

106-
choice = wait_for_sync_choice(repo_full_name, issue_num, token)
90+
if drift_detected:
91+
print_drift_diff(local_doc, aws_doc)
92+
issue_num, _ = create_approval_issue(repo_full_name, token, policy_name, assignees=assignees)
93+
typer.echo(f"✅ Approval issue created: https://github.com/{repo_full_name}/issues/{issue_num}")
94+
choice = wait_for_sync_choice(repo_full_name, issue_num, token, allowed_approvers=assignees)
95+
_handle_choice(choice, local_doc, aws_doc, iam, policy_arn, repo_full_name, token, policy_file, policy_name, issue_num)
96+
else:
97+
if approval_anyway:
98+
issue_num, _ = create_approval_issue(repo_full_name, token, policy_name, assignees=assignees, approval_anyway=True)
99+
typer.echo(f"✅ Forced approval issue created: https://github.com/{repo_full_name}/issues/{issue_num}")
100+
choice = wait_for_sync_choice(repo_full_name, issue_num, token, allowed_approvers=assignees, approval_anyway=True)
101+
if choice == "approve":
102+
_update_aws_policy(iam, policy_arn, local_doc)
103+
typer.echo(f"✅ AWS policy {policy_arn} updated as approved.")
104+
close_issue(repo_full_name, token, issue_num, "✅ Approved and applied. Closing issue.")
105+
else:
106+
typer.echo("❌ Approval rejected. Exiting.")
107+
close_issue(repo_full_name, token, issue_num, "❌ Rejected. Closing issue.")
108+
raise typer.Exit(1)
109+
else:
110+
_update_aws_policy(iam, policy_arn, local_doc)
111+
typer.echo(f"✅ AWS policy {policy_arn} updated to include any local additions.")
112+
typer.echo("✅ No forced approval requested. Exiting.")
107113

114+
def _handle_choice(choice, local_doc, aws_doc, iam, policy_arn, repo, token, policy_file, policy_name, issue_num):
108115
if choice == "local->aws":
109116
merged_doc = merge_policy_documents(local_doc, aws_doc)
110-
try:
111-
_update_aws_policy(iam, policy_arn, merged_doc)
112-
except ValueError as ve:
113-
typer.echo(str(ve))
114-
raise typer.Exit(1)
115-
typer.echo(f"✅ AWS policy {policy_arn} updated with local changes (append-only).")
116-
117+
_apply_aws_update_and_close(iam, policy_arn, merged_doc, repo, token, issue_num, "✅ AWS updated with local changes.")
117118
elif choice == "aws->local":
118-
_update_local_and_create_pr(aws_doc, policy_file, repo_full_name, policy_name, issue_num, token, "from AWS policy")
119-
119+
_update_local_and_create_pr(aws_doc, policy_file, repo, policy_name, issue_num, token, "from AWS policy")
120120
elif choice == "aws<->local":
121121
superset_doc = build_superset_policy(local_doc, aws_doc)
122-
try:
123-
_update_aws_policy(iam, policy_arn, superset_doc)
124-
except ValueError as ve:
125-
typer.echo(str(ve))
126-
raise typer.Exit(1)
127-
typer.echo(f"✅ AWS policy {policy_arn} updated with superset of local + AWS.")
128-
_update_local_and_create_pr(superset_doc, policy_file, repo_full_name, policy_name, issue_num, token, "with superset of local + AWS")
129-
122+
_apply_aws_update_and_close(iam, policy_arn, superset_doc, repo, token, issue_num, "✅ Superset applied.")
123+
_update_local_and_create_pr(superset_doc, policy_file, repo, policy_name, issue_num, token, "with superset of local + AWS")
130124
else:
131125
typer.echo("⏭ No synchronization performed (skip).")
126+
close_issue(repo, token, issue_num, "⏭ No sync chosen. Closing issue.")
127+
128+
def _apply_aws_update_and_close(iam, policy_arn, doc, repo, token, issue_num, message):
129+
_update_aws_policy(iam, policy_arn, doc)
130+
close_issue(repo, token, issue_num, message)
132131

133132
def _update_aws_policy(iam, policy_arn, policy_doc):
134133
sids = [stmt.get("Sid") for stmt in policy_doc.get("Statement", []) if "Sid" in stmt]
135134
if len(sids) != len(set(sids)):
136135
raise ValueError("❌ Merged policy would produce duplicate SIDs. Cannot update AWS policy.")
137-
138136
current_version_id = iam.get_policy(PolicyArn=policy_arn)["Policy"]["DefaultVersionId"]
139137
current_doc = iam.get_policy_version(PolicyArn=policy_arn, VersionId=current_version_id)["PolicyVersion"]["Document"]
140-
141138
if policy_doc == current_doc:
142139
print("✅ Merged policy is identical to existing AWS policy. No update needed.")
143140
return
144-
145141
versions = iam.list_policy_versions(PolicyArn=policy_arn)["Versions"]
146142
if len(versions) >= 5:
147143
oldest = sorted((v for v in versions if not v["IsDefaultVersion"]), key=lambda v: v["CreateDate"])[0]
148144
iam.delete_policy_version(PolicyArn=policy_arn, VersionId=oldest["VersionId"])
149-
150145
iam.create_policy_version(
151146
PolicyArn=policy_arn,
152147
PolicyDocument=json.dumps(policy_doc),
@@ -155,29 +150,13 @@ def _update_aws_policy(iam, policy_arn, policy_doc):
155150
print(f"✅ AWS policy {policy_arn} updated successfully.")
156151

157152
def _update_local_and_create_pr(doc, policy_file, repo_full_name, policy_name, issue_num, token, description=""):
158-
new_content = json.dumps(doc, indent=2)
159153
with open(policy_file, "w") as f:
160-
f.write(new_content)
161-
162-
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
163-
branch = (
164-
f"drift-sync-{policy_name}-{timestamp}"
165-
.replace(' ', '-')
166-
.replace('+', 'plus')
167-
.replace('/', '-')
168-
.strip('-')
169-
.lower()
170-
)
171-
154+
f.write(json.dumps(doc, indent=2))
155+
branch = f"drift-sync-{policy_name}-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}".replace(' ', '-').replace('+', 'plus').replace('/', '-').strip('-').lower()
172156
push_branch(branch)
173-
174157
pr_title = f"Update {policy_file} {description}"
175158
pr_body = f"This PR updates `{policy_file}` {description}.\n\nLinked to issue #{issue_num}."
176-
177159
pr_num, pr_url = create_github_pr(repo_full_name, branch, pr_title, pr_body, issue_num=issue_num)
178-
179-
gh = Github(token)
180-
repo = gh.get_repo(repo_full_name)
181-
issue = repo.get_issue(number=issue_num)
182-
issue.create_comment(f"✅ PR created and linked: {pr_url}. Closing issue.")
183-
issue.edit(state="closed")
160+
if not pr_num:
161+
typer.echo("⚠️ PR creation failed. Manual PR needed.")
162+
close_issue(repo_full_name, token, issue_num, f"✅ PR created and linked: {pr_url}. Closing issue.")

devolv/drift/github_approvals.py

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,36 @@
11
import os
22
import subprocess
3-
from github import Github
43
import typer
4+
from github import Github
55

66
def _get_github_token():
77
token = os.getenv("GITHUB_TOKEN")
88
if not token:
99
raise ValueError(
10-
"❌ GITHUB_TOKEN not set in environment. "
11-
"In your Action, ensure it's passed via input and exported: "
10+
"❌ GITHUB_TOKEN not set in environment.\n"
11+
"💡 Resolution: Set the token in your environment or CI pipeline.\n"
12+
"👉 Example (local): export GITHUB_TOKEN=ghp_yourtoken\n"
13+
"👉 Example (GitHub Action): pass via input and export:\n"
1214
"export GITHUB_TOKEN=${{ inputs.github-token }}"
1315
)
1416
return token
15-
17+
1618
def _get_github_repo(repo_full_name: str):
1719
gh = Github(_get_github_token())
1820
return gh.get_repo(repo_full_name)
1921

2022
def create_github_issue(repo: str, title: str, body: str, assignees: list) -> tuple:
21-
"""
22-
Create a GitHub issue and return (number, url)
23-
"""
2423
try:
2524
repo_obj = _get_github_repo(repo)
2625
issue = repo_obj.create_issue(title=title, body=body, assignees=assignees)
2726
print(f"✅ Created issue #{issue.number} in {repo}: {issue.html_url}")
2827
return issue.number, issue.html_url
2928
except Exception as e:
3029
print(f"❌ Failed to create issue in {repo}: {e}")
31-
raise
30+
print("💡 Resolution: Check if your GitHub token has `repo` scope and the assignees exist in the repo.")
31+
return None, None
3232

3333
def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: str = "main", issue_num: int = None) -> tuple:
34-
"""
35-
Create a GitHub PR. If issue_num is provided, comment on the issue.
36-
Return (PR number, PR URL).
37-
"""
38-
from github import Github
39-
4034
try:
4135
repo_obj = _get_github_repo(repo)
4236
pr = repo_obj.create_pull(
@@ -45,35 +39,28 @@ def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: s
4539
head=head_branch,
4640
base=base
4741
)
48-
#print(f"✅ Created PR #{pr.number} in {repo}: {pr.html_url}")
42+
print(f"✅ Created PR #{pr.number} in {repo}: {pr.html_url}")
4943

5044
if issue_num:
5145
issue = repo_obj.get_issue(number=issue_num)
5246
issue.create_comment(f"A PR has been created for this sync: {pr.html_url}")
53-
47+
5448
return pr.number, pr.html_url
5549

5650
except Exception as e:
5751
print(f"❌ Failed to create PR: {e}")
58-
raise
52+
print("💡 Resolution: Ensure the GitHub token has permission to create PRs. Check branch protection rules and required approvals that may block automation PRs.")
53+
print(f"👉 Suggested: Manually create a PR from `{head_branch}` to `{base}` in `{repo}`.")
54+
return None, None
5955

6056
def push_branch(branch_name: str):
61-
import subprocess
62-
import typer
63-
6457
try:
65-
# Create or switch to branch safely
6658
subprocess.run(["git", "checkout", "-B", branch_name], check=True)
67-
68-
# Ensure Git identity is set
6959
subprocess.run(["git", "config", "user.email", "github-actions@users.noreply.github.com"], check=True)
7060
subprocess.run(["git", "config", "user.name", "github-actions"], check=True)
71-
72-
# Add, commit
7361
subprocess.run(["git", "add", "."], check=True)
7462
subprocess.run(["git", "commit", "-m", f"Update policy: {branch_name}"], check=True)
7563

76-
# Try pushing
7764
try:
7865
subprocess.run(["git", "push", "--set-upstream", "origin", branch_name], check=True)
7966
except subprocess.CalledProcessError:
@@ -84,7 +71,6 @@ def push_branch(branch_name: str):
8471
typer.echo(f"✅ Pushed branch {branch_name} to origin.")
8572

8673
except subprocess.CalledProcessError as e:
87-
typer.echo(f"❌ Git command failed: {e}")
88-
raise typer.Exit(1)
89-
90-
74+
typer.echo(f"❌ Git push failed: {e}")
75+
typer.echo("💡 Resolution: Check if your auth token is valid, branch exists remotely, or branch protection prevents push. Try manual push:")
76+
typer.echo(f"👉 git push -f origin {branch_name}")

0 commit comments

Comments
 (0)