1313)
1414from devolv .drift .issues import create_approval_issue , wait_for_sync_choice
1515from 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
1818app = 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 ()
5457def 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
133132def _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
157152def _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 \n Linked 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." )
0 commit comments