Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions integration/helpers/base_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,6 @@ def tearDown(self):
if self.stack_name:
client = self.client_provider.cfn_client
client.delete_stack(StackName=self.stack_name)
waiter = client.get_waiter("stack_delete_complete")
waiter.wait(StackName=self.stack_name)
if self.output_file_path and os.path.exists(self.output_file_path):
os.remove(self.output_file_path)
if self.sub_input_file_path and os.path.exists(self.sub_input_file_path):
Expand Down
196 changes: 196 additions & 0 deletions integration/setup/companion-stack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,202 @@ Resources:
DependsOn: ApiGatewayLoggingRole
Properties:
CloudWatchRoleArn: !GetAtt ApiGatewayLoggingRole.Arn

TestStackSweeperRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
- !Sub "arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess"
Policies:
- PolicyName: TestStackSweeperPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- cloudformation:DeleteStack
- cloudformation:UpdateTerminationProtection
Resource: !Sub "arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/*sam-integ-stack-*"
- Effect: Allow
Action:
- iam:DeleteRolePolicy
- iam:DetachRolePolicy
- iam:DeleteRole
- iam:DeletePolicyVersion
- iam:DeletePolicy
Resource:
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/hydra-*"
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/sam-integ-stack-*"
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/hydra-*"
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/sam-integ-stack-*"
- Effect: Allow
Action:
- s3:DeleteObject
- s3:DeleteObjectVersion
- s3:DeleteBucket
Resource:
- !Sub "arn:${AWS::Partition}:s3:::*sam-integ-stack-*"
- !Sub "arn:${AWS::Partition}:s3:::*sam-integ-stack-*/*"
- Effect: Allow
Action:
- logs:DeleteLogGroup
Resource: !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*sam-integ-*:*"
- Effect: Allow
Action: '*'
Comment thread
vicheey marked this conversation as resolved.
Resource: '*'
Condition:
ForAnyValue:StringEquals:
"aws:CalledVia": cloudformation.amazonaws.com

TestStackSweeperFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-test-stack-sweeper"
Runtime: python3.10
Comment thread
vicheey marked this conversation as resolved.
Outdated
Handler: index.handler
Timeout: 900
MemorySize: 256
Role: !GetAtt TestStackSweeperRole.Arn
Code:
ZipFile: |
import boto3, time
from datetime import datetime, timezone, timedelta

STACK_PATTERN = 'sam-integ-stack-'
IAM_PATTERN = 'sam-integ-'
ELIGIBLE_STATUSES = [
'CREATE_COMPLETE', 'ROLLBACK_COMPLETE', 'ROLLBACK_FAILED',
'REVIEW_IN_PROGRESS', 'DELETE_FAILED', 'UPDATE_FAILED',
'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_COMPLETE',
'UPDATE_ROLLBACK_FAILED']

def _has_time(ctx):
return ctx.get_remaining_time_in_millis() > 30000

def _is_test_resource(name, strict=False):
pattern = STACK_PATTERN if strict else IAM_PATTERN
return pattern in name and 'companion' not in name
Comment thread
vicheey marked this conversation as resolved.

def handler(event, ctx):
cfn = boto3.client('cloudformation')
iam = boto3.client('iam')
logs = boto3.client('logs')
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)

_sweep_stacks(cfn, iam, cutoff, ctx)
_sweep_log_groups(logs, cutoff, ctx)

def _sweep_stacks(cfn, iam, cutoff, ctx):
deleted = 0
for page in cfn.get_paginator('list_stacks').paginate(StackStatusFilter=ELIGIBLE_STATUSES):
for stack in page['StackSummaries']:
if not _has_time(ctx):
break
Comment thread
vicheey marked this conversation as resolved.
Outdated
Comment thread
vicheey marked this conversation as resolved.
Outdated
name = stack['StackName']
if not _is_test_resource(name, strict=True):
continue
if stack['CreationTime'].replace(tzinfo=timezone.utc) >= cutoff:
continue
try:
cfn.update_termination_protection(EnableTerminationProtection=False, StackName=name)
Comment thread
vicheey marked this conversation as resolved.
Outdated
except:
Comment thread
vicheey marked this conversation as resolved.
Outdated
Comment thread
vicheey marked this conversation as resolved.
Outdated
pass
if stack['StackStatus'] == 'DELETE_FAILED':
_fix_and_retry(cfn, iam, name)
try:
cfn.delete_stack(StackName=name)
deleted += 1
time.sleep(1)
Comment thread
vicheey marked this conversation as resolved.
except Exception as e:
print(f"delete_stack {name}: {e}")
print(f"Stacks: {deleted} deleted")

def _fix_and_retry(cfn, iam, stack_name):
try:
for event in cfn.describe_stack_events(StackName=stack_name)['StackEvents']:
if event.get('ResourceStatus') != 'DELETE_FAILED':
continue
resource_type = event.get('ResourceType', '')
resource_id = event.get('PhysicalResourceId', '')
if not resource_id or not _is_test_resource(resource_id):
continue
if resource_type == 'AWS::IAM::Role':
_force_delete_role(iam, resource_id)
elif resource_type == 'AWS::IAM::Policy':
_force_delete_policy(iam, resource_id)
elif resource_type == 'AWS::S3::Bucket':
try:
bucket = boto3.resource('s3').Bucket(resource_id)
bucket.object_versions.delete()
bucket.objects.delete()
except: pass
except Exception as e:
print(f"fix_and_retry {stack_name}: {e}")

def _force_delete_role(iam, role_name):
try:
for p in iam.list_role_policies(RoleName=role_name)['PolicyNames']:
iam.delete_role_policy(RoleName=role_name, PolicyName=p)
for p in iam.list_attached_role_policies(RoleName=role_name)['AttachedPolicies']:
iam.detach_role_policy(RoleName=role_name, PolicyArn=p['PolicyArn'])
iam.delete_role(RoleName=role_name)
except: pass

def _force_delete_policy(iam, arn):
try:
for page in iam.get_paginator('list_entities_for_policy').paginate(PolicyArn=arn, EntityFilter='Role'):
for r in page['PolicyRoles']:
iam.detach_role_policy(RoleName=r['RoleName'], PolicyArn=arn)
for v in iam.list_policy_versions(PolicyArn=arn)['Versions']:
if not v['IsDefaultVersion']:
iam.delete_policy_version(PolicyArn=arn, VersionId=v['VersionId'])
iam.delete_policy(PolicyArn=arn)
except: pass

def _sweep_log_groups(logs, cutoff, ctx):
cutoff_ms = int(cutoff.timestamp() * 1000)
deleted = 0
for page in logs.get_paginator('describe_log_groups').paginate(logGroupNamePrefix='/aws/lambda/'):
Comment thread
vicheey marked this conversation as resolved.
Outdated
for log_group in page['logGroups']:
if not _has_time(ctx):
return
name = log_group['logGroupName']
if STACK_PATTERN not in name:
continue
if log_group.get('creationTime', 0) >= cutoff_ms:
continue
try:
logs.delete_log_group(logGroupName=name)
deleted += 1
time.sleep(1)
except: pass
print(f"Log groups: {deleted} deleted")

TestStackSweeperSchedule:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: 'rate(6 hours)'
State: ENABLED
Targets:
- Arn: !GetAtt TestStackSweeperFunction.Arn
Id: TestStackSweeperTarget

TestStackSweeperInvokePermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !GetAtt TestStackSweeperFunction.Arn
Action: lambda:InvokeFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt TestStackSweeperSchedule.Arn

Outputs:
PreCreatedVpc:
Description: Pre-created VPC that can be used inside other tests
Expand Down
Loading