|
| 1 | +import json |
| 2 | +import boto3 |
1 | 3 | import typer |
2 | | -from pathlib import Path |
3 | | -from devolv.drift import aws_fetcher, file_loader, report |
4 | | -import botocore |
| 4 | +import os |
5 | 5 |
|
| 6 | +from devolv.drift.aws_fetcher import get_aws_policy_document, merge_policy_documents |
| 7 | +from devolv.drift.github_issues import create_approval_issue, wait_for_sync_choice |
| 8 | +from devolv.drift.github_approvals import create_github_pr |
| 9 | +from devolv.drift.report import detect_and_print_drift |
| 10 | + |
| 11 | +app = typer.Typer() |
| 12 | + |
| 13 | +@app.command() |
6 | 14 | def drift( |
7 | | - policy_name: str = typer.Option(..., "--policy-name", help="IAM policy name in AWS"), |
8 | | - file: str = typer.Option(..., "--file", help="Path to local policy file") |
| 15 | + policy_name: str = typer.Option(..., "--policy-name", help="Name of the IAM policy"), |
| 16 | + policy_file: str = typer.Option(..., "--file", help="Path to local policy file"), |
| 17 | + account_id: str = typer.Option(None, "--account-id", help="AWS Account ID (optional, auto-detected if not provided)"), |
| 18 | + approvers: str = typer.Option("", help="Comma-separated GitHub usernames for approval"), |
| 19 | + approval_anyway: bool = typer.Option(False, "--approval-anyway", help="Request approval even if no drift") |
9 | 20 | ): |
10 | | - try: |
11 | | - local_path = Path(file) |
12 | | - if not local_path.exists(): |
13 | | - typer.secho(f"❌ File not found: {file}", fg=typer.colors.RED) |
14 | | - raise typer.Exit(1) |
| 21 | + """ |
| 22 | + Detect drift between local policy (file) and AWS policy (ARN), |
| 23 | + create GitHub issue for approval, and perform sync based on comment. |
| 24 | + """ |
| 25 | + # Auto-detect account ID if not provided |
| 26 | + if not account_id: |
| 27 | + sts = boto3.client("sts") |
| 28 | + account_id = sts.get_caller_identity()["Account"] |
15 | 29 |
|
16 | | - local_policy = file_loader.load_policy(local_path) |
17 | | - aws_policy = aws_fetcher.get_policy(policy_name) |
| 30 | + policy_arn = f"arn:aws:iam::{account_id}:policy/{policy_name}" |
18 | 31 |
|
19 | | - if aws_policy is None: |
20 | | - typer.secho(f"⚠️ Could not fetch AWS policy '{policy_name}'.", fg=typer.colors.YELLOW) |
21 | | - raise typer.Exit(1) |
| 32 | + # Load local policy document |
| 33 | + try: |
| 34 | + with open(policy_file) as f: |
| 35 | + local_doc = json.load(f) |
| 36 | + except FileNotFoundError: |
| 37 | + typer.echo(f"❌ Local policy file {policy_file} not found.") |
| 38 | + raise typer.Exit(1) |
22 | 39 |
|
23 | | - report.generate_diff_report(local_policy, aws_policy) |
| 40 | + # Fetch AWS policy document |
| 41 | + aws_doc = get_aws_policy_document(policy_arn) |
| 42 | + drift = detect_and_print_drift(local_doc, aws_doc) |
24 | 43 |
|
25 | | - except botocore.exceptions.ClientError as e: |
26 | | - error = e.response.get("Error", {}) |
27 | | - code = error.get("Code", "UnknownError") |
28 | | - message = error.get("Message", "") |
| 44 | + # If no drift and no approval flag, exit |
| 45 | + if not drift and not approval_anyway: |
| 46 | + typer.echo("✅ No drift detected. Use --approval-anyway to force approval.") |
| 47 | + raise typer.Exit() |
29 | 48 |
|
30 | | - typer.secho(f"❌ AWS API error: {code}", fg=typer.colors.RED) |
| 49 | + # Create GitHub issue |
| 50 | + token = os.getenv("GITHUB_TOKEN") |
| 51 | + if not token: |
| 52 | + typer.echo("❌ GITHUB_TOKEN not set in environment.") |
| 53 | + raise typer.Exit(1) |
31 | 54 |
|
32 | | - if code == "AccessDenied" and ' because ' in message: |
33 | | - main, reason = message.split(' because ', 1) |
34 | | - typer.echo(f" → {main.strip()}") |
35 | | - typer.echo(f" → Reason: {reason.strip()}") |
36 | | - else: |
37 | | - typer.echo(f" → {message.strip()}") |
| 55 | + issue_num = create_approval_issue("owner/repo", token, policy_name) |
| 56 | + typer.echo(f"Issue #{issue_num} created for approval.") |
38 | 57 |
|
39 | | - typer.echo(f"⚠️ Could not fetch AWS policy '{policy_name}'.") |
40 | | - typer.echo(f"💡 Tip: Ensure your IAM user has permission for {code}-related actions.") |
41 | | - raise typer.Exit(1) |
| 58 | + # Wait for sync choice comment |
| 59 | + choice = wait_for_sync_choice("owner/repo", issue_num, token) |
42 | 60 |
|
43 | | - except typer.Exit: |
44 | | - raise # Let Typer handle clean exits |
| 61 | + if choice == "local->aws": |
| 62 | + merged_doc = merge_policy_documents(local_doc, aws_doc) |
| 63 | + iam = boto3.client("iam") |
| 64 | + versions = iam.list_policy_versions(PolicyArn=policy_arn)['Versions'] |
| 65 | + if len(versions) >= 5: |
| 66 | + oldest = sorted((v for v in versions if not v['IsDefaultVersion']), |
| 67 | + key=lambda v: v['CreateDate'])[0] |
| 68 | + iam.delete_policy_version(PolicyArn=policy_arn, VersionId=oldest['VersionId']) |
| 69 | + iam.create_policy_version( |
| 70 | + PolicyArn=policy_arn, |
| 71 | + PolicyDocument=json.dumps(merged_doc), |
| 72 | + SetAsDefault=True |
| 73 | + ) |
| 74 | + typer.echo(f"✅ AWS policy {policy_arn} updated with local changes (append-only).") |
45 | 75 |
|
46 | | - except Exception as e: |
47 | | - typer.secho(f"❌ Unexpected error: {str(e)}", fg=typer.colors.RED) |
48 | | - raise typer.Exit(1) |
| 76 | + elif choice == "aws->local": |
| 77 | + new_content = json.dumps(aws_doc, indent=2) |
| 78 | + with open(policy_file, "w") as f: |
| 79 | + f.write(new_content) |
| 80 | + branch = f"update-policy-{policy_name}" |
| 81 | + pr_title = f"Update {policy_file} from AWS policy" |
| 82 | + pr_body = "This PR updates the local policy file with the AWS default version." |
| 83 | + pr_num = create_github_pr("owner/repo", branch, pr_title, pr_body) |
| 84 | + typer.echo(f"✅ Created PR #{pr_num}: updated {policy_file} from AWS policy.") |
| 85 | + |
| 86 | + else: |
| 87 | + typer.echo("⏭ No synchronization performed (skip).") |
0 commit comments