Skip to content

Commit 5f8fb1f

Browse files
committed
testing drift v2 in flow
1 parent 6c8e57e commit 5f8fb1f

9 files changed

Lines changed: 229 additions & 157 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ jobs:
3636
with:
3737
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
3838
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
39-
aws-region: ap-south-1
40-
role-to-assume: arn:aws:iam::149704127940:role/DevolvCIRole
39+
aws-region: *******
40+
role-to-assume: arn:aws:iam::*******:role/DevolvCIRole
4141
role-skip-session-tagging: true
4242

4343
- name: Run Devolv Drift Detection
4444
uses: devolvdev/devolv-actions@v1
4545
with:
46-
tool: drift
47-
policy-name: DevolvTestPolicyHuge
48-
path: ./test-devolv-policy.json
46+
tool: drift # drift = detect IAM drift (validator also available)
47+
policy-name: DevolvTestPolicyHuge # Name of the IAM policy in AWS
48+
path: ./test-devolv-policy.json # Path to your local IaC policy file

.github/workflows/test.yml

Lines changed: 0 additions & 30 deletions
This file was deleted.

.github/workflows/test.yml_bkp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Devolv Action Test
2+
3+
on:
4+
workflow_dispatch: {}
5+
6+
jobs:
7+
test-devolv:
8+
runs-on: ubuntu-latest
9+
10+
steps:
11+
- name: Checkout code
12+
uses: actions/checkout@v3
13+
14+
- name: Run Devolv Drift
15+
uses: your-org/devolv-action@v2
16+
with:
17+
tool: drift
18+
policy-name: DevolvTestPolicy
19+
path: ./test-devolv-policy.json
20+
github-token: ${{ secrets.GITHUB_TOKEN }}
21+
22+
- name: Run Devolv Validate
23+
uses: your-org/devolv-action@v2
24+
with:
25+
tool: validate
26+
path: ./test-devolv-policy.json
27+
github-token: ${{ secrets.GITHUB_TOKEN }}

devolv/drift/aws_fetcher.py

Lines changed: 25 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,26 @@
11
import boto3
2-
import botocore
3-
4-
def get_policy(policy_name=None, policy_arn=None):
5-
client = boto3.client("iam")
6-
sts_client = boto3.client("sts")
7-
8-
try:
9-
if policy_arn:
10-
# Directly fetch by provided ARN
11-
return _fetch_policy_document(client, policy_arn)
12-
13-
# No ARN provided → try to construct ARN
14-
account_id = sts_client.get_caller_identity()["Account"]
15-
constructed_arn = f"arn:aws:iam::{account_id}:policy/{policy_name}"
16-
17-
try:
18-
return _fetch_policy_document(client, constructed_arn)
19-
except botocore.exceptions.ClientError as e:
20-
if e.response["Error"]["Code"] not in ["NoSuchEntity", "AccessDenied"]:
21-
raise # Unexpected error, re-raise
22-
23-
# Fallthrough to attempt list-based discovery
24-
pass
25-
26-
# Fallback: attempt to list policies (if permission allows)
27-
paginator = client.get_paginator('list_policies')
28-
for page in paginator.paginate(Scope='Local'):
29-
for policy in page['Policies']:
30-
if policy['PolicyName'] == policy_name:
31-
return _fetch_policy_document(client, policy['Arn'])
32-
33-
# As last attempt, try AWS-managed policies
34-
for page in paginator.paginate(Scope='AWS'):
35-
for policy in page['Policies']:
36-
if policy['PolicyName'] == policy_name:
37-
return _fetch_policy_document(client, policy['Arn'])
38-
39-
# Not found at all
40-
return None
41-
42-
except botocore.exceptions.ClientError as e:
43-
error = e.response.get("Error", {})
44-
code = error.get("Code", "UnknownError")
45-
message = error.get("Message", "")
46-
print(f"❌ AWS API error during policy fetch: {code}{message}")
47-
return None
48-
except Exception as e:
49-
print(f"❌ Unexpected error during policy fetch: {str(e)}")
50-
return None
51-
52-
def _fetch_policy_document(client, policy_arn):
53-
policy_meta = client.get_policy(PolicyArn=policy_arn)
54-
version_id = policy_meta["Policy"]["DefaultVersionId"]
55-
version = client.get_policy_version(
56-
PolicyArn=policy_arn,
57-
VersionId=version_id
58-
)
59-
return version["PolicyVersion"]["Document"]
2+
import json
3+
4+
def get_aws_policy_document(policy_arn: str) -> dict:
5+
"""
6+
Fetch the JSON document of the default version of a managed IAM policy.
7+
"""
8+
iam = boto3.client("iam")
9+
policy = iam.get_policy(PolicyArn=policy_arn)['Policy']
10+
default_version = policy['DefaultVersionId']
11+
version = iam.get_policy_version(PolicyArn=policy_arn, VersionId=default_version)
12+
return version['PolicyVersion']['Document']
13+
14+
def merge_policy_documents(local_doc: dict, aws_doc: dict) -> dict:
15+
"""
16+
Merge statements by appending any local-only statements to the AWS document.
17+
This is an "append-only" merge (we do not delete existing AWS statements).
18+
"""
19+
aws_stmts = aws_doc.get("Statement", [])
20+
local_stmts = local_doc.get("Statement", [])
21+
merged = list(aws_stmts) # copy existing AWS statements
22+
for stmt in local_stmts:
23+
if stmt not in aws_stmts:
24+
merged.append(stmt)
25+
aws_doc["Statement"] = merged
26+
return aws_doc

devolv/drift/cli.py

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,87 @@
1+
import json
2+
import boto3
13
import typer
2-
from pathlib import Path
3-
from devolv.drift import aws_fetcher, file_loader, report
4-
import botocore
4+
import os
55

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()
614
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")
920
):
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"]
1529

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}"
1831

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)
2239

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)
2443

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()
2948

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)
3154

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.")
3857

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)
4260

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).")
4575

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).")

devolv/drift/comparator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from deepdiff import DeepDiff
22

3+
34
def compare_policies(local, aws):
45
diff = DeepDiff(local, aws, ignore_order=True)
56
return diff

devolv/drift/github_approvals.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import os
2+
from github import Github
3+
4+
def _get_github_token():
5+
token = os.getenv("GITHUB_TOKEN")
6+
if not token:
7+
raise ValueError(
8+
"❌ GITHUB_TOKEN not set in environment. "
9+
"In your Action, ensure it's passed via input and exported: "
10+
"export GITHUB_TOKEN=${{ inputs.github-token }}"
11+
)
12+
return token
13+
14+
def _get_github_repo(repo_full_name: str):
15+
gh = Github(_get_github_token())
16+
return gh.get_repo(repo_full_name)
17+
18+
def create_github_issue(repo: str, title: str, body: str, assignees: list) -> int:
19+
"""
20+
Create a GitHub issue using the GitHub API.
21+
"""
22+
try:
23+
repo_obj = _get_github_repo(repo)
24+
issue = repo_obj.create_issue(title=title, body=body, assignees=assignees)
25+
print(f"✅ Created issue #{issue.number} in {repo}")
26+
return issue.number
27+
except Exception as e:
28+
print(f"❌ Failed to create issue in {repo}: {e}")
29+
raise
30+
31+
def create_github_pr(repo: str, head_branch: str, title: str, body: str, base: str = "main") -> int:
32+
"""
33+
Create a GitHub pull request using the GitHub API.
34+
"""
35+
try:
36+
repo_obj = _get_github_repo(repo)
37+
pr = repo_obj.create_pull(
38+
title=title,
39+
body=body,
40+
head=head_branch,
41+
base=base
42+
)
43+
print(f"✅ Created PR #{pr.number} in {repo}")
44+
return pr.number
45+
except Exception as e:
46+
print(f"❌ Failed to create PR in {repo}: {e}")
47+
raise

devolv/drift/issues.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from github import Github
2+
import time
3+
4+
def create_approval_issue(repo_full_name, token, policy_name):
5+
g = Github(token)
6+
repo = g.get_repo(repo_full_name)
7+
body = (
8+
f"Drift detected for policy `{policy_name}`.\n\n"
9+
"Please comment:\n"
10+
"- `local->aws` to sync local changes to AWS\n"
11+
"- `aws->local` to sync AWS changes to local file\n"
12+
"- `skip` to do nothing"
13+
)
14+
issue = repo.create_issue(
15+
title=f"Devolv Drift Sync Approval Needed: {policy_name}",
16+
body=body
17+
)
18+
return issue.number
19+
20+
def wait_for_sync_choice(repo_full_name, issue_number, token):
21+
g = Github(token)
22+
repo = g.get_repo(repo_full_name)
23+
issue = repo.get_issue(number=issue_number)
24+
25+
while True:
26+
comments = issue.get_comments()
27+
for comment in comments:
28+
content = comment.body.strip().lower()
29+
if content in ["local->aws", "aws->local", "skip"]:
30+
return content
31+
print("Waiting for approval comment...")
32+
time.sleep(30) # Poll every 30 seconds

0 commit comments

Comments
 (0)