44import os
55import 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
88from devolv .drift .issues import create_approval_issue , wait_for_sync_choice
99from devolv .drift .github_approvals import create_github_pr
1010from devolv .drift .report import detect_and_print_drift
1111
1212app = typer .Typer ()
1313
1414def 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 \n Linked 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 } " )
0 commit comments