Skip to content

Commit 34f5496

Browse files
authored
feat: async test stack cleanup for integration test (#3930)
1 parent bb1e69f commit 34f5496

2 files changed

Lines changed: 192 additions & 2 deletions

File tree

integration/helpers/base_test.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,6 @@ def tearDown(self):
127127
if self.stack_name:
128128
client = self.client_provider.cfn_client
129129
client.delete_stack(StackName=self.stack_name)
130-
waiter = client.get_waiter("stack_delete_complete")
131-
waiter.wait(StackName=self.stack_name)
132130
if self.output_file_path and os.path.exists(self.output_file_path):
133131
os.remove(self.output_file_path)
134132
if self.sub_input_file_path and os.path.exists(self.sub_input_file_path):

integration/setup/companion-stack.yaml

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,198 @@ Resources:
256256
DependsOn: ApiGatewayLoggingRole
257257
Properties:
258258
CloudWatchRoleArn: !GetAtt ApiGatewayLoggingRole.Arn
259+
260+
TestStackSweeperRole:
261+
Type: AWS::IAM::Role
262+
Properties:
263+
AssumeRolePolicyDocument:
264+
Version: '2012-10-17'
265+
Statement:
266+
- Effect: Allow
267+
Principal:
268+
Service: lambda.amazonaws.com
269+
Action: sts:AssumeRole
270+
ManagedPolicyArns:
271+
- !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
272+
- !Sub "arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess"
273+
Policies:
274+
- PolicyName: TestStackSweeperPolicy
275+
PolicyDocument:
276+
Version: '2012-10-17'
277+
Statement:
278+
- Effect: Allow
279+
Action:
280+
- cloudformation:DeleteStack
281+
Resource: !Sub "arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/*sam-integ-stack-*"
282+
- Effect: Allow
283+
Action:
284+
- iam:DeleteRolePolicy
285+
- iam:DetachRolePolicy
286+
- iam:DeleteRole
287+
- iam:DeletePolicyVersion
288+
- iam:DeletePolicy
289+
Resource:
290+
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/hydra-*"
291+
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/sam-integ-stack-*"
292+
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/hydra-*"
293+
- !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/sam-integ-stack-*"
294+
- Effect: Allow
295+
Action:
296+
- s3:DeleteObject
297+
- s3:DeleteObjectVersion
298+
- s3:DeleteBucket
299+
Resource:
300+
- !Sub "arn:${AWS::Partition}:s3:::*sam-integ-stack-*"
301+
- !Sub "arn:${AWS::Partition}:s3:::*sam-integ-stack-*/*"
302+
- Effect: Allow
303+
Action:
304+
- logs:DeleteLogGroup
305+
Resource: !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*sam-integ-*"
306+
- Effect: Allow
307+
Action: '*'
308+
Resource: '*'
309+
Condition:
310+
ForAnyValue:StringEquals:
311+
aws:CalledVia: cloudformation.amazonaws.com
312+
313+
TestStackSweeperFunction:
314+
Type: AWS::Lambda::Function
315+
Properties:
316+
FunctionName: !Sub "${AWS::StackName}-test-stack-sweeper"
317+
Runtime: python3.13
318+
Handler: index.handler
319+
Timeout: 900
320+
MemorySize: 256
321+
Role: !GetAtt TestStackSweeperRole.Arn
322+
Code:
323+
ZipFile: |
324+
import boto3, time
325+
from datetime import datetime, timezone, timedelta
326+
327+
STACK_PATTERN = 'sam-integ-stack-'
328+
IAM_PATTERN = 'sam-integ-'
329+
ELIGIBLE_STATUSES = [
330+
'CREATE_COMPLETE', 'ROLLBACK_COMPLETE', 'ROLLBACK_FAILED',
331+
'REVIEW_IN_PROGRESS', 'DELETE_FAILED', 'UPDATE_FAILED',
332+
'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_COMPLETE',
333+
'UPDATE_ROLLBACK_FAILED']
334+
335+
def _has_time(ctx):
336+
return ctx.get_remaining_time_in_millis() > 30000
337+
338+
def _is_test(name, strict=False):
339+
pattern = STACK_PATTERN if strict else IAM_PATTERN
340+
return pattern in name and 'companion' not in name
341+
342+
def handler(event, ctx):
343+
cfn = boto3.client('cloudformation')
344+
iam = boto3.client('iam')
345+
logs = boto3.client('logs')
346+
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
347+
348+
_sweep_stacks(cfn, iam, cutoff, ctx)
349+
_sweep_log_groups(logs, cutoff, ctx)
350+
351+
def _sweep_stacks(cfn, iam, cutoff, ctx):
352+
deleted = []
353+
for page in cfn.get_paginator('list_stacks').paginate(StackStatusFilter=ELIGIBLE_STATUSES):
354+
for stack in page['StackSummaries']:
355+
if not _has_time(ctx):
356+
print(f"Attempt to delete ({len(deleted)}) stacks: {deleted}")
357+
return
358+
name = stack['StackName']
359+
if not _is_test(name, strict=True):
360+
continue
361+
if stack['CreationTime'].replace(tzinfo=timezone.utc) >= cutoff:
362+
continue
363+
if stack['StackStatus'] == 'DELETE_FAILED':
364+
_fix_and_retry(cfn, iam, name)
365+
try:
366+
cfn.delete_stack(StackName=name)
367+
deleted.append(name)
368+
time.sleep(1)
369+
except Exception as e:
370+
print(f"delete_stack {name}: {e}")
371+
print(f"Attempt to delete ({len(deleted)}) stacks: {deleted}")
372+
373+
def _fix_and_retry(cfn, iam, stack_name):
374+
try:
375+
for event in cfn.describe_stack_events(StackName=stack_name)['StackEvents']:
376+
if event.get('ResourceStatus') != 'DELETE_FAILED':
377+
continue
378+
resource_type = event.get('ResourceType', '')
379+
resource_id = event.get('PhysicalResourceId', '')
380+
if not resource_id or not _is_test(resource_id):
381+
continue
382+
if resource_type == 'AWS::IAM::Role':
383+
_force_delete_role(iam, resource_id)
384+
elif resource_type == 'AWS::IAM::Policy':
385+
_force_delete_policy(iam, resource_id)
386+
elif resource_type == 'AWS::S3::Bucket':
387+
try:
388+
bucket = boto3.resource('s3').Bucket(resource_id)
389+
bucket.object_versions.delete()
390+
bucket.objects.delete()
391+
except Exception: pass
392+
except Exception as e:
393+
print(f"fix_and_retry {stack_name}: {e}")
394+
395+
def _force_delete_role(iam, role_name):
396+
try:
397+
for p in iam.list_role_policies(RoleName=role_name)['PolicyNames']:
398+
iam.delete_role_policy(RoleName=role_name, PolicyName=p)
399+
for p in iam.list_attached_role_policies(RoleName=role_name)['AttachedPolicies']:
400+
iam.detach_role_policy(RoleName=role_name, PolicyArn=p['PolicyArn'])
401+
iam.delete_role(RoleName=role_name)
402+
except Exception: pass
403+
404+
def _force_delete_policy(iam, arn):
405+
try:
406+
for page in iam.get_paginator('list_entities_for_policy').paginate(PolicyArn=arn, EntityFilter='Role'):
407+
for r in page['PolicyRoles']:
408+
iam.detach_role_policy(RoleName=r['RoleName'], PolicyArn=arn)
409+
for v in iam.list_policy_versions(PolicyArn=arn)['Versions']:
410+
if not v['IsDefaultVersion']:
411+
iam.delete_policy_version(PolicyArn=arn, VersionId=v['VersionId'])
412+
iam.delete_policy(PolicyArn=arn)
413+
except Exception: pass
414+
415+
def _sweep_log_groups(logs, cutoff, ctx):
416+
cutoff_ms = int(cutoff.timestamp() * 1000)
417+
deleted = 0
418+
for page in logs.get_paginator('describe_log_groups').paginate():
419+
for log_group in page['logGroups']:
420+
if not _has_time(ctx):
421+
return
422+
name = log_group['logGroupName']
423+
if STACK_PATTERN not in name:
424+
continue
425+
if log_group.get('creationTime', 0) >= cutoff_ms:
426+
continue
427+
try:
428+
logs.delete_log_group(logGroupName=name)
429+
deleted += 1
430+
time.sleep(1)
431+
except Exception: pass
432+
print(f"Log groups: {deleted} deleted")
433+
434+
TestStackSweeperSchedule:
435+
Type: AWS::Events::Rule
436+
Properties:
437+
ScheduleExpression: rate(6 hours)
438+
State: ENABLED
439+
Targets:
440+
- Arn: !GetAtt TestStackSweeperFunction.Arn
441+
Id: TestStackSweeperTarget
442+
443+
TestStackSweeperInvokePermission:
444+
Type: AWS::Lambda::Permission
445+
Properties:
446+
FunctionName: !GetAtt TestStackSweeperFunction.Arn
447+
Action: lambda:InvokeFunction
448+
Principal: events.amazonaws.com
449+
SourceArn: !GetAtt TestStackSweeperSchedule.Arn
450+
259451
Outputs:
260452
PreCreatedVpc:
261453
Description: Pre-created VPC that can be used inside other tests

0 commit comments

Comments
 (0)