diff --git a/integration-tests/bin/app.ts b/integration-tests/bin/app.ts index a7cb568ee..4bc9c2266 100644 --- a/integration-tests/bin/app.ts +++ b/integration-tests/bin/app.ts @@ -5,6 +5,7 @@ import {Base} from '../lib/stacks/base'; import {Otlp} from '../lib/stacks/otlp'; import {Snapstart} from '../lib/stacks/snapstart'; import {LambdaManagedInstancesStack} from '../lib/stacks/lmi'; +import {StackCleanup} from '../lib/stacks/stack-cleanup'; import {ACCOUNT, getIdentifier, REGION} from '../config'; import {CapacityProviderStack} from "../lib/capacity-provider"; @@ -38,5 +39,8 @@ const stacks = [ // Tag all stacks so we can easily clean them up stacks.forEach(stack => stack.addStackTag("extension_integration_test", "true")) +new StackCleanup(app, `integ-stack-cleanup`, { + env, +}); app.synth(); diff --git a/integration-tests/lambda/stack-cleanup/index.mjs b/integration-tests/lambda/stack-cleanup/index.mjs new file mode 100644 index 000000000..0caf93a9c --- /dev/null +++ b/integration-tests/lambda/stack-cleanup/index.mjs @@ -0,0 +1,102 @@ +import { CloudFormationClient, ListStacksCommand, DescribeStacksCommand, DeleteStackCommand } from "@aws-sdk/client-cloudformation"; + +const client = new CloudFormationClient({}); + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +export const handler = async (event) => { + console.log('Starting stack cleanup process...'); + + const cutoffTime = Date.now() - SEVEN_DAYS_MS; + const stacksToDelete = []; + + try { + const listCommand = new ListStacksCommand({ + StackStatusFilter: [ + 'CREATE_COMPLETE', + 'ROLLBACK_COMPLETE', + 'UPDATE_COMPLETE', + 'UPDATE_ROLLBACK_COMPLETE', + 'IMPORT_COMPLETE', + 'IMPORT_ROLLBACK_COMPLETE' + ] + }); + + const listResponse = await client.send(listCommand); + console.log(`Found ${listResponse.StackSummaries?.length || 0} stacks to evaluate`); + + for (const stackSummary of listResponse.StackSummaries || []) { + const stackName = stackSummary.StackName; + const lastModifiedTime = stackSummary.LastUpdatedTime || stackSummary.CreationTime; + + if (lastModifiedTime.getTime() > cutoffTime) { + continue; + } + + try { + const describeCommand = new DescribeStacksCommand({ + StackName: stackName + }); + + const describeResponse = await client.send(describeCommand); + const stack = describeResponse.Stacks?.[0]; + + if (!stack) { + continue; + } + + const hasIntegrationTestTag = stack.Tags?.some( + tag => tag.Key === 'extension_integration_test' && tag.Value === 'true' + ); + + if (hasIntegrationTestTag) { + stacksToDelete.push(stackName); + } else { + console.log(`Skipping ${stackName} - does not have extension_integration_test tag`); + } + } catch (error) { + console.error(`Error describing stack ${stackName}:`, error.message); + } + } + + console.log(`Found ${stacksToDelete.length} stacks to delete`); + const deletionResults = []; + + for (const stackName of stacksToDelete) { + try { + console.log(`Deleting stack: ${stackName}`); + const deleteCommand = new DeleteStackCommand({ + StackName: stackName + }); + await client.send(deleteCommand); + deletionResults.push({ + stackName, + success: true + }); + console.log(`Successfully initiated deletion of ${stackName}`); + } catch (error) { + console.error(`Error deleting stack ${stackName}:`, error.message); + deletionResults.push({ + stackName, + success: false, + errorMessage: error.message + }); + } + } + + console.log('Stack cleanup complete:', JSON.stringify(deletionResults, null, 2)); + + return { + statusCode: 200, + body: JSON.stringify(deletionResults) + }; + } catch (error) { + console.error('Error during stack cleanup:', error); + return { + statusCode: 500, + body: JSON.stringify({ + error: error.message + }) + }; + } +}; diff --git a/integration-tests/lambda/stack-cleanup/package.json b/integration-tests/lambda/stack-cleanup/package.json new file mode 100644 index 000000000..d86c41434 --- /dev/null +++ b/integration-tests/lambda/stack-cleanup/package.json @@ -0,0 +1,8 @@ +{ + "name": "stack-cleanup", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@aws-sdk/client-cloudformation": "^3.0.0" + } +} diff --git a/integration-tests/lib/stacks/stack-cleanup.ts b/integration-tests/lib/stacks/stack-cleanup.ts new file mode 100644 index 000000000..3babf0d69 --- /dev/null +++ b/integration-tests/lib/stacks/stack-cleanup.ts @@ -0,0 +1,59 @@ +import * as cdk from 'aws-cdk-lib'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as events from 'aws-cdk-lib/aws-events'; +import * as targets from 'aws-cdk-lib/aws-events-targets'; +import { Construct } from 'constructs'; +import { createLogGroup } from '../util'; + +export class StackCleanup extends cdk.Stack { + constructor(scope: Construct, id: string, props: cdk.StackProps) { + super(scope, id, props); + + const functionName = `${id}-cleanup-lambda`; + + const cleanupFunction = new lambda.Function(this, functionName, { + runtime: lambda.Runtime.NODEJS_24_X, + architecture: lambda.Architecture.ARM_64, + handler: 'index.handler', + code: lambda.Code.fromAsset('./lambda/stack-cleanup'), + functionName: functionName, + timeout: cdk.Duration.minutes(15), + memorySize: 512, + environment: { + TS: Date.now().toString() + }, + logGroup: createLogGroup(this, functionName) + }); + + cleanupFunction.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'cloudformation:ListStacks', + 'cloudformation:DescribeStacks', + 'cloudformation:DeleteStack', + 'cloudformation:DescribeStackEvents', + 'cloudformation:DescribeStackResources', + 'lambda:DeleteFunction', + 'lambda:RemovePermission', + 'logs:DeleteLogGroup', + 'logs:DeleteRetentionPolicy', + 'iam:DeleteRole', + 'iam:DeleteRolePolicy', + 'iam:DetachRolePolicy', + 'iam:GetRole', + 'iam:GetRolePolicy', + 'iam:ListRolePolicies', + 'iam:ListAttachedRolePolicies' + ], + resources: ['*'] + })); + + const rule = new events.Rule(this, 'DailyCleanupRule', { + schedule: events.Schedule.rate(cdk.Duration.days(1)), + description: 'Triggers stack cleanup Lambda once per day' + }); + rule.addTarget(new targets.LambdaFunction(cleanupFunction)); + + } +}