@@ -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+
259451Outputs :
260452 PreCreatedVpc :
261453 Description : Pre-created VPC that can be used inside other tests
0 commit comments