Skip to content

Commit 246e34f

Browse files
committed
testing updated v2
1 parent 436ea4d commit 246e34f

File tree

6 files changed

+82
-43
lines changed

6 files changed

+82
-43
lines changed

.github/workflows/test-devolv-action.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
path: ./test-devolv-policy.json
3737
approvers: RkSinghDeo, devolvdev
3838
github-token: ${{ secrets.GITHUB_TOKEN }}
39+
approval-anyway: true
3940

4041
- name: Run Devolv Validate
4142
uses: devolvdev/devolv-actions@v2

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.32"
22

devolv/drift/cli.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,20 @@ def push_branch(branch_name: str):
4040
def detect_drift(local_doc, aws_doc) -> bool:
4141
local_statements = {json.dumps(s, sort_keys=True) for s in local_doc.get("Statement", [])}
4242
aws_statements = {json.dumps(s, sort_keys=True) for s in aws_doc.get("Statement", [])}
43-
4443
missing_in_local = aws_statements - local_statements
45-
4644
if missing_in_local:
4745
typer.echo("❌ Drift detected: Local is missing permissions present in AWS.")
4846
return True
49-
5047
typer.echo("✅ No removal drift detected (local may have extra permissions; that's fine).")
5148
return False
5249

50+
def close_issue(repo_full_name, token, issue_num, comment):
51+
gh = Github(token)
52+
repo = gh.get_repo(repo_full_name)
53+
issue = repo.get_issue(number=issue_num)
54+
issue.create_comment(comment)
55+
issue.edit(state="closed")
56+
5357
@app.command()
5458
def drift(
5559
policy_name: str = typer.Option(..., "--policy-name", help="Name of the IAM policy"),
@@ -84,6 +88,7 @@ def drift(
8488
typer.echo(str(ve))
8589
raise typer.Exit(1)
8690
typer.echo(f"✅ AWS policy {policy_arn} updated to include any local additions.")
91+
8792
if not approval_anyway:
8893
typer.echo("✅ No forced approval requested. Exiting.")
8994
return
@@ -99,11 +104,15 @@ def drift(
99104
raise typer.Exit(1)
100105

101106
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)
107+
issue_num, _ = create_approval_issue(
108+
repo_full_name, token, policy_name, assignees=assignees, approval_anyway=approval_anyway
109+
)
103110
issue_url = f"https://github.com/{repo_full_name}/issues/{issue_num}"
104111
typer.echo(f"✅ Approval issue created: {issue_url}")
105112

106-
choice = wait_for_sync_choice(repo_full_name, issue_num, token)
113+
choice = wait_for_sync_choice(
114+
repo_full_name, issue_num, token, allowed_approvers=assignees, approval_anyway=approval_anyway
115+
)
107116

108117
if choice == "local->aws":
109118
merged_doc = merge_policy_documents(local_doc, aws_doc)
@@ -113,6 +122,7 @@ def drift(
113122
typer.echo(str(ve))
114123
raise typer.Exit(1)
115124
typer.echo(f"✅ AWS policy {policy_arn} updated with local changes (append-only).")
125+
close_issue(repo_full_name, token, issue_num, "✅ AWS updated with local changes. Closing issue.")
116126

117127
elif choice == "aws->local":
118128
_update_local_and_create_pr(aws_doc, policy_file, repo_full_name, policy_name, issue_num, token, "from AWS policy")
@@ -127,8 +137,18 @@ def drift(
127137
typer.echo(f"✅ AWS policy {policy_arn} updated with superset of local + AWS.")
128138
_update_local_and_create_pr(superset_doc, policy_file, repo_full_name, policy_name, issue_num, token, "with superset of local + AWS")
129139

140+
elif choice == "approve":
141+
typer.echo("✅ Approved without sync action. Closing issue.")
142+
close_issue(repo_full_name, token, issue_num, "✅ Approved without sync action. Closing issue.")
143+
144+
elif choice == "reject":
145+
typer.echo("❌ Approval rejected. Closing issue.")
146+
close_issue(repo_full_name, token, issue_num, "❌ Approval rejected. Closing issue.")
147+
raise typer.Exit(1)
148+
130149
else:
131150
typer.echo("⏭ No synchronization performed (skip).")
151+
close_issue(repo_full_name, token, issue_num, "⏭ No sync chosen. Closing issue.")
132152

133153
def _update_aws_policy(iam, policy_arn, policy_doc):
134154
sids = [stmt.get("Sid") for stmt in policy_doc.get("Statement", []) if "Sid" in stmt]

devolv/drift/github_approvals.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from github import Github
44
import typer
55

6+
67
def _get_github_token():
78
token = os.getenv("GITHUB_TOKEN")
89
if not token:
@@ -12,31 +13,32 @@ def _get_github_token():
1213
"export GITHUB_TOKEN=${{ inputs.github-token }}"
1314
)
1415
return token
15-
16+
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

20-
def create_github_issue(repo: str, title: str, body: str, assignees: list) -> tuple:
22+
23+
def create_github_issue(repo: str, title: str, body: str, assignees: list = None) -> tuple:
2124
"""
2225
Create a GitHub issue and return (number, url)
2326
"""
2427
try:
2528
repo_obj = _get_github_repo(repo)
26-
issue = repo_obj.create_issue(title=title, body=body, assignees=assignees)
29+
issue = repo_obj.create_issue(title=title, body=body, assignees=assignees or [])
2730
print(f"✅ Created issue #{issue.number} in {repo}: {issue.html_url}")
2831
return issue.number, issue.html_url
2932
except Exception as e:
3033
print(f"❌ Failed to create issue in {repo}: {e}")
3134
raise
3235

36+
3337
def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: str = "main", issue_num: int = None) -> tuple:
3438
"""
3539
Create a GitHub PR. If issue_num is provided, comment on the issue.
3640
Return (PR number, PR URL).
3741
"""
38-
from github import Github
39-
4042
try:
4143
repo_obj = _get_github_repo(repo)
4244
pr = repo_obj.create_pull(
@@ -45,22 +47,19 @@ def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: s
4547
head=head_branch,
4648
base=base
4749
)
48-
#print(f"✅ Created PR #{pr.number} in {repo}: {pr.html_url}")
4950

5051
if issue_num:
5152
issue = repo_obj.get_issue(number=issue_num)
5253
issue.create_comment(f"A PR has been created for this sync: {pr.html_url}")
53-
54+
5455
return pr.number, pr.html_url
5556

5657
except Exception as e:
5758
print(f"❌ Failed to create PR: {e}")
5859
raise
5960

60-
def push_branch(branch_name: str):
61-
import subprocess
62-
import typer
6361

62+
def push_branch(branch_name: str):
6463
try:
6564
# Create or switch to branch safely
6665
subprocess.run(["git", "checkout", "-B", branch_name], check=True)
@@ -69,7 +68,7 @@ def push_branch(branch_name: str):
6968
subprocess.run(["git", "config", "user.email", "github-actions@users.noreply.github.com"], check=True)
7069
subprocess.run(["git", "config", "user.name", "github-actions"], check=True)
7170

72-
# Add, commit
71+
# Add and commit
7372
subprocess.run(["git", "add", "."], check=True)
7473
subprocess.run(["git", "commit", "-m", f"Update policy: {branch_name}"], check=True)
7574

@@ -86,5 +85,3 @@ def push_branch(branch_name: str):
8685
except subprocess.CalledProcessError as e:
8786
typer.echo(f"❌ Git command failed: {e}")
8887
raise typer.Exit(1)
89-
90-

devolv/drift/issues.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,51 @@
11
from github import Github
22
import time
33

4-
def create_approval_issue(repo_full_name, token, policy_name, assignees=None):
4+
def create_approval_issue(repo_full_name, token, policy_name, assignees=None, approval_anyway=False):
5+
"""
6+
Create a GitHub issue requesting approval for IAM policy sync.
7+
Supports forced approval requests (approval_anyway=True).
8+
"""
59
gh = Github(token)
610
repo = gh.get_repo(repo_full_name)
711

812
approver_list = ", ".join([f"@{a}" for a in assignees]) if assignees else "anyone"
913

10-
title = f"Approval needed for IAM policy: {policy_name}"
11-
body = (
12-
f"Please review and approve the sync for `{policy_name}`.\n\n"
13-
f"✅ **Allowed approvers:** {approver_list}\n\n"
14-
"**Reply with one of the following commands to proceed:**\n"
15-
"- `local->aws` → Apply local policy changes to AWS\n"
16-
"- `aws->local` → Update local policy file from AWS\n"
17-
"- `aws<->local` → Sync both ways (superset, update AWS + local)\n"
18-
"- `skip` → Skip this sync"
19-
)
14+
if approval_anyway:
15+
title = f"Approval needed (forced) for IAM policy: {policy_name}"
16+
body = (
17+
f"Approval is required even though no drift was detected for `{policy_name}`.\n\n"
18+
f"✅ **Allowed approvers:** {approver_list}\n\n"
19+
"**Reply with one of the following commands to proceed:**\n"
20+
"- `approve` → Approve current policy state; no changes applied\n"
21+
"- `reject` → Reject current state; no changes applied"
22+
)
23+
else:
24+
title = f"Approval needed for IAM policy: {policy_name}"
25+
body = (
26+
f"Please review and approve the sync for `{policy_name}`.\n\n"
27+
f"✅ **Allowed approvers:** {approver_list}\n\n"
28+
"**Reply with one of the following commands to proceed:**\n"
29+
"- `local->aws` → Apply local policy changes to AWS\n"
30+
"- `aws->local` → Update local policy file from AWS\n"
31+
"- `aws<->local` → Sync both ways (superset, update AWS + local)\n"
32+
"- `skip` → Skip this sync"
33+
)
2034

2135
issue = repo.create_issue(
2236
title=title,
2337
body=body,
2438
assignees=assignees or []
2539
)
2640

27-
#print(f"✅ Created issue #{issue.number} in {repo_full_name}: {issue.html_url}")
2841
return issue.number, issue.html_url
2942

30-
31-
def wait_for_sync_choice(repo_full_name, issue_number, token, allowed_approvers=None):
32-
g = Github(token)
33-
repo = g.get_repo(repo_full_name)
43+
def wait_for_sync_choice(repo_full_name, issue_number, token, allowed_approvers=None, approval_anyway=False):
44+
"""
45+
Poll for an approved sync command or forced approval (approve/reject).
46+
"""
47+
gh = Github(token)
48+
repo = gh.get_repo(repo_full_name)
3449
issue = repo.get_issue(number=issue_number)
3550

3651
allowed_approvers = [a.lower() for a in (allowed_approvers or [])]
@@ -45,9 +60,12 @@ def wait_for_sync_choice(repo_full_name, issue_number, token, allowed_approvers=
4560
print(f"Ignoring comment from unauthorized user: {commenter}")
4661
continue
4762

48-
if content in ["local->aws", "aws->local", "aws<->local", "skip"]:
49-
return content
63+
if approval_anyway:
64+
if content in ["approve", "reject"]:
65+
return content
66+
else:
67+
if content in ["local->aws", "aws->local", "aws<->local", "skip"]:
68+
return content
5069

5170
print("Waiting for approval comment...")
5271
time.sleep(30)
53-

devolv/drift/report.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from rich.console import Console
44
from rich.text import Text
55
import typer
6+
67
def clean_policy(policy):
78
"""
89
Remove empty statements ({} entries) from the policy's 'Statement' list.
@@ -14,24 +15,26 @@ def clean_policy(policy):
1415
return policy
1516

1617
def detect_drift(local_doc, aws_doc) -> bool:
17-
"""Detect removal drift: AWS has permissions missing from local (danger)."""
18+
"""
19+
Detect removal drift:
20+
Returns True if AWS has permissions not present in local (local missing permissions).
21+
"""
1822
local_statements = {json.dumps(s, sort_keys=True) for s in local_doc.get("Statement", [])}
1923
aws_statements = {json.dumps(s, sort_keys=True) for s in aws_doc.get("Statement", [])}
2024

2125
missing_in_local = aws_statements - local_statements
2226

2327
if missing_in_local:
2428
typer.echo("❌ Drift detected: Local is missing permissions present in AWS.")
25-
# No need to print each JSON line — rich diff will handle details
2629
return True
2730

2831
typer.echo("✅ No removal drift detected (local may have extra permissions; that's fine).")
2932
return False
3033

31-
3234
def generate_diff_lines(local_doc: dict, aws_doc: dict):
3335
"""
34-
Generate a unified diff between local and AWS policy JSONs.
36+
Generate a unified diff between pretty-printed local and AWS policy JSONs.
37+
Returns a list of diff lines.
3538
"""
3639
local_str = json.dumps(clean_policy(local_doc), indent=2, sort_keys=True)
3740
aws_str = json.dumps(clean_policy(aws_doc), indent=2, sort_keys=True)
@@ -46,7 +49,7 @@ def generate_diff_lines(local_doc: dict, aws_doc: dict):
4649

4750
def print_drift_diff(local_doc: dict, aws_doc: dict):
4851
"""
49-
Pretty-print a unified diff using Rich.
52+
Pretty-print a unified diff using Rich formatting.
5053
"""
5154
console = Console()
5255
diff_lines = generate_diff_lines(local_doc, aws_doc)

0 commit comments

Comments
 (0)