From 8b741afd035493545c787b96c7e367b34ddabdfd Mon Sep 17 00:00:00 2001 From: Sam Chung Date: Mon, 8 Jan 2024 18:25:51 +1100 Subject: [PATCH] Add blue-green deployments to sqs cdk template (#1327) * bundle cdk * remove env variable * optimise * set externalModules * add hotswap * Create rude-bobcats-unite.md * Run `skuba format` * introduce hotswap * add watch * Update rude-bobcats-unite.md * Run `skuba format` * add commas * add blue green * add missing deps * Update nine-coins-hear.md * simplify * add more packages * fix errors * add timeout 30 * update snapshot * add hooks * add cleanup * update changeset * simplify PR * update * tweak changeset * remove extra dep * revert * update snapshot * fix promise * mock time * fixes * lint * add empty return * fix snapshot * update snap * remove layer pruning * update snap * Update nine-coins-hear.md * Update template/lambda-sqs-worker-cdk/infra/appStack.ts * Update app.ts * Update nine-coins-hear.md * Update appStack.ts * add bash * Update snapshots * apply recommendations * Try something * Run `skuba format` * Comment out SNS construct * Try removing `as const` * Run `skuba format` * More commented out code :) * Update snapshot * Hmm * Switch to `skuba node` * Hoist esbuild for now * Simplify with `addAlias` * Update snapshot * Update snapshot * Uncomment SNS topic integration --------- Co-authored-by: skuba <34733141+seek-oss-ci@users.noreply.github.com> Co-authored-by: Ryan Ling --- .changeset/nine-coins-hear.md | 5 + template/base/_.npmrc | 1 + template/lambda-sqs-worker-cdk/Dockerfile | 3 + template/lambda-sqs-worker-cdk/cdk.json | 8 +- .../infra/__snapshots__/appStack.test.ts.snap | 1420 ++++++++++++++++- .../infra/appStack.test.ts | 20 +- .../lambda-sqs-worker-cdk/infra/appStack.ts | 142 +- template/lambda-sqs-worker-cdk/package.json | 3 + .../shared/context-types.ts | 1 + template/lambda-sqs-worker-cdk/src/app.ts | 15 +- .../lambda-sqs-worker-cdk/src/postHook.ts | 154 ++ template/lambda-sqs-worker-cdk/src/preHook.ts | 95 ++ 12 files changed, 1774 insertions(+), 93 deletions(-) create mode 100644 .changeset/nine-coins-hear.md create mode 100644 template/lambda-sqs-worker-cdk/src/postHook.ts create mode 100644 template/lambda-sqs-worker-cdk/src/preHook.ts diff --git a/.changeset/nine-coins-hear.md b/.changeset/nine-coins-hear.md new file mode 100644 index 000000000..e5a3d4582 --- /dev/null +++ b/.changeset/nine-coins-hear.md @@ -0,0 +1,5 @@ +--- +'skuba': patch +--- + +template/lambda-sqs-worker-cdk: Add blue-green deployment, smoke test and version pruning functionality diff --git a/template/base/_.npmrc b/template/base/_.npmrc index caff84ae2..d4a2d8682 100644 --- a/template/base/_.npmrc +++ b/template/base/_.npmrc @@ -2,5 +2,6 @@ public-hoist-pattern[]=@types* public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*prettier* +public-hoist-pattern[]=esbuild public-hoist-pattern[]=tsconfig-seek # end managed by skuba diff --git a/template/lambda-sqs-worker-cdk/Dockerfile b/template/lambda-sqs-worker-cdk/Dockerfile index 0eadacd6f..b2c0cc730 100644 --- a/template/lambda-sqs-worker-cdk/Dockerfile +++ b/template/lambda-sqs-worker-cdk/Dockerfile @@ -2,6 +2,9 @@ FROM --platform=${BUILDPLATFORM:-<%- platformName %>} node:20-alpine AS dev-deps +# Needed for cdk +RUN apk add --no-cache bash + RUN corepack enable pnpm RUN pnpm config set store-dir /root/.pnpm-store diff --git a/template/lambda-sqs-worker-cdk/cdk.json b/template/lambda-sqs-worker-cdk/cdk.json index 26b50b83c..5fe41e920 100644 --- a/template/lambda-sqs-worker-cdk/cdk.json +++ b/template/lambda-sqs-worker-cdk/cdk.json @@ -1,5 +1,5 @@ { - "app": "npx ts-node infra/index.ts", + "app": "pnpm exec skuba node infra/index.ts", "context": { "global": { "appName": "<%- serviceName %>" @@ -10,7 +10,8 @@ "environment": { "SOMETHING": "dev" } - } + }, + "sourceSnsTopicArn": "TODO: sourceSnsTopicArn" }, "prod": { "workerLambda": { @@ -18,7 +19,8 @@ "environment": { "SOMETHING": "prod" } - } + }, + "sourceSnsTopicArn": "TODO: sourceSnsTopicArn" } }, "progress": "events", diff --git a/template/lambda-sqs-worker-cdk/infra/__snapshots__/appStack.test.ts.snap b/template/lambda-sqs-worker-cdk/infra/__snapshots__/appStack.test.ts.snap index 2ef08a6fc..a0a516c5c 100644 --- a/template/lambda-sqs-worker-cdk/infra/__snapshots__/appStack.test.ts.snap +++ b/template/lambda-sqs-worker-cdk/infra/__snapshots__/appStack.test.ts.snap @@ -2,6 +2,115 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` { + "Mappings": { + "ServiceprincipalMap": { + "af-south-1": { + "codedeploy": "codedeploy.af-south-1.amazonaws.com", + }, + "ap-east-1": { + "codedeploy": "codedeploy.ap-east-1.amazonaws.com", + }, + "ap-northeast-1": { + "codedeploy": "codedeploy.ap-northeast-1.amazonaws.com", + }, + "ap-northeast-2": { + "codedeploy": "codedeploy.ap-northeast-2.amazonaws.com", + }, + "ap-northeast-3": { + "codedeploy": "codedeploy.ap-northeast-3.amazonaws.com", + }, + "ap-south-1": { + "codedeploy": "codedeploy.ap-south-1.amazonaws.com", + }, + "ap-south-2": { + "codedeploy": "codedeploy.ap-south-2.amazonaws.com", + }, + "ap-southeast-1": { + "codedeploy": "codedeploy.ap-southeast-1.amazonaws.com", + }, + "ap-southeast-2": { + "codedeploy": "codedeploy.ap-southeast-2.amazonaws.com", + }, + "ap-southeast-3": { + "codedeploy": "codedeploy.ap-southeast-3.amazonaws.com", + }, + "ap-southeast-4": { + "codedeploy": "codedeploy.ap-southeast-4.amazonaws.com", + }, + "ca-central-1": { + "codedeploy": "codedeploy.ca-central-1.amazonaws.com", + }, + "cn-north-1": { + "codedeploy": "codedeploy.cn-north-1.amazonaws.com.cn", + }, + "cn-northwest-1": { + "codedeploy": "codedeploy.cn-northwest-1.amazonaws.com.cn", + }, + "eu-central-1": { + "codedeploy": "codedeploy.eu-central-1.amazonaws.com", + }, + "eu-central-2": { + "codedeploy": "codedeploy.eu-central-2.amazonaws.com", + }, + "eu-north-1": { + "codedeploy": "codedeploy.eu-north-1.amazonaws.com", + }, + "eu-south-1": { + "codedeploy": "codedeploy.eu-south-1.amazonaws.com", + }, + "eu-south-2": { + "codedeploy": "codedeploy.eu-south-2.amazonaws.com", + }, + "eu-west-1": { + "codedeploy": "codedeploy.eu-west-1.amazonaws.com", + }, + "eu-west-2": { + "codedeploy": "codedeploy.eu-west-2.amazonaws.com", + }, + "eu-west-3": { + "codedeploy": "codedeploy.eu-west-3.amazonaws.com", + }, + "il-central-1": { + "codedeploy": "codedeploy.il-central-1.amazonaws.com", + }, + "me-central-1": { + "codedeploy": "codedeploy.me-central-1.amazonaws.com", + }, + "me-south-1": { + "codedeploy": "codedeploy.me-south-1.amazonaws.com", + }, + "sa-east-1": { + "codedeploy": "codedeploy.sa-east-1.amazonaws.com", + }, + "us-east-1": { + "codedeploy": "codedeploy.us-east-1.amazonaws.com", + }, + "us-east-2": { + "codedeploy": "codedeploy.us-east-2.amazonaws.com", + }, + "us-gov-east-1": { + "codedeploy": "codedeploy.us-gov-east-1.amazonaws.com", + }, + "us-gov-west-1": { + "codedeploy": "codedeploy.us-gov-west-1.amazonaws.com", + }, + "us-iso-east-1": { + "codedeploy": "codedeploy.amazonaws.com", + }, + "us-iso-west-1": { + "codedeploy": "codedeploy.amazonaws.com", + }, + "us-isob-east-1": { + "codedeploy": "codedeploy.amazonaws.com", + }, + "us-west-1": { + "codedeploy": "codedeploy.us-west-1.amazonaws.com", + }, + "us-west-2": { + "codedeploy": "codedeploy.us-west-2.amazonaws.com", + }, + }, + }, "Parameters": { "BootstrapVersion": { "Default": "/cdk-bootstrap/hnb659fds/version", @@ -10,6 +119,189 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` }, }, "Resources": { + "codedeployalarm9F48D05F": { + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Dimensions": [ + { + "Name": "FunctionName", + "Value": { + "Ref": "worker28EA3E30", + }, + }, + { + "Name": "Resource", + "Value": { + "Fn::Join": [ + "", + [ + { + "Ref": "worker28EA3E30", + }, + ":live", + ], + ], + }, + }, + ], + "EvaluationPeriods": 1, + "MetricName": "Errors", + "Namespace": "AWS/Lambda", + "Period": 60, + "Statistic": "Sum", + "Threshold": 1, + }, + "Type": "AWS::CloudWatch::Alarm", + }, + "codedeployapplicationF49B9864": { + "Properties": { + "ComputePlatform": "Lambda", + }, + "Type": "AWS::CodeDeploy::Application", + }, + "codedeploygroup441B094B": { + "Properties": { + "AlarmConfiguration": { + "Alarms": [ + { + "Name": { + "Ref": "codedeployalarm9F48D05F", + }, + }, + ], + "Enabled": true, + }, + "ApplicationName": { + "Ref": "codedeployapplicationF49B9864", + }, + "AutoRollbackConfiguration": { + "Enabled": true, + "Events": [ + "DEPLOYMENT_FAILURE", + "DEPLOYMENT_STOP_ON_ALARM", + ], + }, + "DeploymentConfigName": "CodeDeployDefault.LambdaAllAtOnce", + "DeploymentStyle": { + "DeploymentOption": "WITH_TRAFFIC_CONTROL", + "DeploymentType": "BLUE_GREEN", + }, + "ServiceRoleArn": { + "Fn::GetAtt": [ + "codedeploygroupServiceRole1BD49E37", + "Arn", + ], + }, + }, + "Type": "AWS::CodeDeploy::DeploymentGroup", + }, + "codedeploygroupServiceRole1BD49E37": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::FindInMap": [ + "ServiceprincipalMap", + { + "Ref": "AWS::Region", + }, + "codedeploy", + ], + }, + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSCodeDeployRoleForLambdaLimited", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "codedeploygroupServiceRoleDefaultPolicy2027BC9A": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "workerprehook415B13CE", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "workerprehook415B13CE", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "workerposthook150842D6", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "workerposthook150842D6", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "codedeploygroupServiceRoleDefaultPolicy2027BC9A", + "Roles": [ + { + "Ref": "codedeploygroupServiceRole1BD49E37", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, "kmskey49FBC3B3": { "DeletionPolicy": "Retain", "Properties": { @@ -135,16 +427,7 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` }, "Type": "AWS::KMS::Alias", }, - "topic69831491": { - "Properties": { - "KmsMasterKeyId": { - "Fn::GetAtt": [ - "kmskey49FBC3B3", - "Arn", - ], - }, - "TopicName": "serviceName", - }, + "sourcetopic7C3DC892": { "Type": "AWS::SNS::Topic", }, "worker28EA3E30": { @@ -162,6 +445,7 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` }, "S3Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.zip", }, + "Description": "Updated at 1212-12-12T12:12:12.121Z", "Environment": { "Variables": { "NODE_ENV": "production", @@ -184,9 +468,82 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` ], }, "Runtime": "nodejs20.x", + "Timeout": 30, }, "Type": "AWS::Lambda::Function", }, + "workerAliaslive62FE5FAF": { + "Properties": { + "Description": "The Lambda version currently receiving traffic", + "FunctionName": { + "Ref": "worker28EA3E30", + }, + "FunctionVersion": { + "Fn::GetAtt": [ + "workerCurrentVersionFA5B4BF42b0ddfc326dbf01ad4617d855ec5ac63", + "Version", + ], + }, + "Name": "live", + }, + "Type": "AWS::Lambda::Alias", + "UpdatePolicy": { + "CodeDeployLambdaAliasUpdate": { + "AfterAllowTrafficHook": { + "Ref": "workerposthook150842D6", + }, + "ApplicationName": { + "Ref": "codedeployapplicationF49B9864", + }, + "BeforeAllowTrafficHook": { + "Ref": "workerprehook415B13CE", + }, + "DeploymentGroupName": { + "Ref": "codedeploygroup441B094B", + }, + }, + }, + }, + "workerAliasliveSqsEventSourceappStackworkerqueue8281B9F42DEDB47B": { + "Properties": { + "EventSourceArn": { + "Fn::GetAtt": [ + "workerqueueA05CE5C6", + "Arn", + ], + }, + "FunctionName": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 6, + { + "Fn::Split": [ + ":", + { + "Ref": "workerAliaslive62FE5FAF", + }, + ], + }, + ], + }, + ":live", + ], + ], + }, + }, + "Type": "AWS::Lambda::EventSourceMapping", + }, + "workerCurrentVersionFA5B4BF42b0ddfc326dbf01ad4617d855ec5ac63": { + "Properties": { + "FunctionName": { + "Ref": "worker28EA3E30", + }, + }, + "Type": "AWS::Lambda::Version", + }, "workerServiceRole2130CC7F": { "Properties": { "AssumeRolePolicyDocument": { @@ -260,56 +617,342 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` }, "Type": "AWS::IAM::Policy", }, - "workerSqsEventSourceappStackworkerqueue8281B9F47B9F582B": { + "workerposthook150842D6": { + "DependsOn": [ + "workerposthookServiceRoleDefaultPolicy6A92F69C", + "workerposthookServiceRole25452300", + ], "Properties": { - "EventSourceArn": { - "Fn::GetAtt": [ - "workerqueueA05CE5C6", - "Arn", - ], + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.zip", }, - "FunctionName": { - "Ref": "worker28EA3E30", + "Environment": { + "Variables": { + "FUNCTION_NAME_TO_PRUNE": { + "Ref": "worker28EA3E30", + }, + "NODE_ENV": "production", + "NODE_OPTIONS": "--enable-source-maps", + "SOMETHING": "dev", + }, }, - }, - "Type": "AWS::Lambda::EventSourceMapping", - }, - "workerqueueA05CE5C6": { - "DeletionPolicy": "Delete", - "Properties": { - "KmsMasterKeyId": { + "FunctionName": "serviceName-post-hook", + "Handler": "index.handler", + "KmsKeyArn": { "Fn::GetAtt": [ "kmskey49FBC3B3", "Arn", ], }, - "QueueName": "serviceName", - "RedrivePolicy": { - "deadLetterTargetArn": { - "Fn::GetAtt": [ - "workerqueuedlq42262778", - "Arn", - ], - }, - "maxReceiveCount": 3, + "Role": { + "Fn::GetAtt": [ + "workerposthookServiceRole25452300", + "Arn", + ], }, + "Runtime": "nodejs20.x", + "Timeout": 30, }, - "Type": "AWS::SQS::Queue", - "UpdateReplacePolicy": "Delete", + "Type": "AWS::Lambda::Function", }, - "workerqueuePolicy97054CB4": { + "workerposthookServiceRole25452300": { "Properties": { - "PolicyDocument": { + "AssumeRolePolicyDocument": { "Statement": [ { - "Action": "sqs:SendMessage", - "Condition": { - "ArnEquals": { - "aws:SourceArn": { - "Ref": "topic69831491", - }, - }, - }, + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "workerposthookServiceRoleDefaultPolicy6A92F69C": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "lambda:ListAliases", + "lambda:ListVersionsByFunction", + "lambda:DeleteFunction", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + { + "Action": "codedeploy:PutLifecycleEventHookExecutionStatus", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":codedeploy:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":deploymentgroup:", + { + "Ref": "codedeployapplicationF49B9864", + }, + "/", + { + "Ref": "codedeploygroup441B094B", + }, + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "workerposthookServiceRoleDefaultPolicy6A92F69C", + "Roles": [ + { + "Ref": "workerposthookServiceRole25452300", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "workerprehook415B13CE": { + "DependsOn": [ + "workerprehookServiceRoleDefaultPolicy991A21B9", + "workerprehookServiceRole8F8D9379", + ], + "Properties": { + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.zip", + }, + "Environment": { + "Variables": { + "FUNCTION_NAME_TO_INVOKE": { + "Ref": "worker28EA3E30", + }, + "NODE_ENV": "production", + "NODE_OPTIONS": "--enable-source-maps", + "SOMETHING": "dev", + }, + }, + "FunctionName": "serviceName-pre-hook", + "Handler": "index.handler", + "KmsKeyArn": { + "Fn::GetAtt": [ + "kmskey49FBC3B3", + "Arn", + ], + }, + "Role": { + "Fn::GetAtt": [ + "workerprehookServiceRole8F8D9379", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Timeout": 120, + }, + "Type": "AWS::Lambda::Function", + }, + "workerprehookServiceRole8F8D9379": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "workerprehookServiceRoleDefaultPolicy991A21B9": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + { + "Action": "codedeploy:PutLifecycleEventHookExecutionStatus", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":codedeploy:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":deploymentgroup:", + { + "Ref": "codedeployapplicationF49B9864", + }, + "/", + { + "Ref": "codedeploygroup441B094B", + }, + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "workerprehookServiceRoleDefaultPolicy991A21B9", + "Roles": [ + { + "Ref": "workerprehookServiceRole8F8D9379", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "workerqueueA05CE5C6": { + "DeletionPolicy": "Delete", + "Properties": { + "KmsMasterKeyId": { + "Fn::GetAtt": [ + "kmskey49FBC3B3", + "Arn", + ], + }, + "QueueName": "serviceName", + "RedrivePolicy": { + "deadLetterTargetArn": { + "Fn::GetAtt": [ + "workerqueuedlq42262778", + "Arn", + ], + }, + "maxReceiveCount": 3, + }, + }, + "Type": "AWS::SQS::Queue", + "UpdateReplacePolicy": "Delete", + }, + "workerqueuePolicy97054CB4": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sqs:SendMessage", + "Condition": { + "ArnEquals": { + "aws:SourceArn": { + "Ref": "sourcetopic7C3DC892", + }, + }, + }, "Effect": "Allow", "Principal": { "Service": "sns.amazonaws.com", @@ -332,7 +975,7 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` }, "Type": "AWS::SQS::QueuePolicy", }, - "workerqueueappStacktopic0CA45134AFB31FF4": { + "workerqueueappStacksourcetopic613C6BDBD2F224F5": { "DependsOn": [ "workerqueuePolicy97054CB4", ], @@ -345,7 +988,7 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` }, "Protocol": "sqs", "TopicArn": { - "Ref": "topic69831491", + "Ref": "sourcetopic7C3DC892", }, }, "Type": "AWS::SNS::Subscription", @@ -397,6 +1040,115 @@ exports[`returns expected CloudFormation stack for dev 1`] = ` exports[`returns expected CloudFormation stack for prod 1`] = ` { + "Mappings": { + "ServiceprincipalMap": { + "af-south-1": { + "codedeploy": "codedeploy.af-south-1.amazonaws.com", + }, + "ap-east-1": { + "codedeploy": "codedeploy.ap-east-1.amazonaws.com", + }, + "ap-northeast-1": { + "codedeploy": "codedeploy.ap-northeast-1.amazonaws.com", + }, + "ap-northeast-2": { + "codedeploy": "codedeploy.ap-northeast-2.amazonaws.com", + }, + "ap-northeast-3": { + "codedeploy": "codedeploy.ap-northeast-3.amazonaws.com", + }, + "ap-south-1": { + "codedeploy": "codedeploy.ap-south-1.amazonaws.com", + }, + "ap-south-2": { + "codedeploy": "codedeploy.ap-south-2.amazonaws.com", + }, + "ap-southeast-1": { + "codedeploy": "codedeploy.ap-southeast-1.amazonaws.com", + }, + "ap-southeast-2": { + "codedeploy": "codedeploy.ap-southeast-2.amazonaws.com", + }, + "ap-southeast-3": { + "codedeploy": "codedeploy.ap-southeast-3.amazonaws.com", + }, + "ap-southeast-4": { + "codedeploy": "codedeploy.ap-southeast-4.amazonaws.com", + }, + "ca-central-1": { + "codedeploy": "codedeploy.ca-central-1.amazonaws.com", + }, + "cn-north-1": { + "codedeploy": "codedeploy.cn-north-1.amazonaws.com.cn", + }, + "cn-northwest-1": { + "codedeploy": "codedeploy.cn-northwest-1.amazonaws.com.cn", + }, + "eu-central-1": { + "codedeploy": "codedeploy.eu-central-1.amazonaws.com", + }, + "eu-central-2": { + "codedeploy": "codedeploy.eu-central-2.amazonaws.com", + }, + "eu-north-1": { + "codedeploy": "codedeploy.eu-north-1.amazonaws.com", + }, + "eu-south-1": { + "codedeploy": "codedeploy.eu-south-1.amazonaws.com", + }, + "eu-south-2": { + "codedeploy": "codedeploy.eu-south-2.amazonaws.com", + }, + "eu-west-1": { + "codedeploy": "codedeploy.eu-west-1.amazonaws.com", + }, + "eu-west-2": { + "codedeploy": "codedeploy.eu-west-2.amazonaws.com", + }, + "eu-west-3": { + "codedeploy": "codedeploy.eu-west-3.amazonaws.com", + }, + "il-central-1": { + "codedeploy": "codedeploy.il-central-1.amazonaws.com", + }, + "me-central-1": { + "codedeploy": "codedeploy.me-central-1.amazonaws.com", + }, + "me-south-1": { + "codedeploy": "codedeploy.me-south-1.amazonaws.com", + }, + "sa-east-1": { + "codedeploy": "codedeploy.sa-east-1.amazonaws.com", + }, + "us-east-1": { + "codedeploy": "codedeploy.us-east-1.amazonaws.com", + }, + "us-east-2": { + "codedeploy": "codedeploy.us-east-2.amazonaws.com", + }, + "us-gov-east-1": { + "codedeploy": "codedeploy.us-gov-east-1.amazonaws.com", + }, + "us-gov-west-1": { + "codedeploy": "codedeploy.us-gov-west-1.amazonaws.com", + }, + "us-iso-east-1": { + "codedeploy": "codedeploy.amazonaws.com", + }, + "us-iso-west-1": { + "codedeploy": "codedeploy.amazonaws.com", + }, + "us-isob-east-1": { + "codedeploy": "codedeploy.amazonaws.com", + }, + "us-west-1": { + "codedeploy": "codedeploy.us-west-1.amazonaws.com", + }, + "us-west-2": { + "codedeploy": "codedeploy.us-west-2.amazonaws.com", + }, + }, + }, "Parameters": { "BootstrapVersion": { "Default": "/cdk-bootstrap/hnb659fds/version", @@ -405,6 +1157,189 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` }, }, "Resources": { + "codedeployalarm9F48D05F": { + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Dimensions": [ + { + "Name": "FunctionName", + "Value": { + "Ref": "worker28EA3E30", + }, + }, + { + "Name": "Resource", + "Value": { + "Fn::Join": [ + "", + [ + { + "Ref": "worker28EA3E30", + }, + ":live", + ], + ], + }, + }, + ], + "EvaluationPeriods": 1, + "MetricName": "Errors", + "Namespace": "AWS/Lambda", + "Period": 60, + "Statistic": "Sum", + "Threshold": 1, + }, + "Type": "AWS::CloudWatch::Alarm", + }, + "codedeployapplicationF49B9864": { + "Properties": { + "ComputePlatform": "Lambda", + }, + "Type": "AWS::CodeDeploy::Application", + }, + "codedeploygroup441B094B": { + "Properties": { + "AlarmConfiguration": { + "Alarms": [ + { + "Name": { + "Ref": "codedeployalarm9F48D05F", + }, + }, + ], + "Enabled": true, + }, + "ApplicationName": { + "Ref": "codedeployapplicationF49B9864", + }, + "AutoRollbackConfiguration": { + "Enabled": true, + "Events": [ + "DEPLOYMENT_FAILURE", + "DEPLOYMENT_STOP_ON_ALARM", + ], + }, + "DeploymentConfigName": "CodeDeployDefault.LambdaAllAtOnce", + "DeploymentStyle": { + "DeploymentOption": "WITH_TRAFFIC_CONTROL", + "DeploymentType": "BLUE_GREEN", + }, + "ServiceRoleArn": { + "Fn::GetAtt": [ + "codedeploygroupServiceRole1BD49E37", + "Arn", + ], + }, + }, + "Type": "AWS::CodeDeploy::DeploymentGroup", + }, + "codedeploygroupServiceRole1BD49E37": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::FindInMap": [ + "ServiceprincipalMap", + { + "Ref": "AWS::Region", + }, + "codedeploy", + ], + }, + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSCodeDeployRoleForLambdaLimited", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "codedeploygroupServiceRoleDefaultPolicy2027BC9A": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "workerprehook415B13CE", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "workerprehook415B13CE", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "workerposthook150842D6", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "workerposthook150842D6", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "codedeploygroupServiceRoleDefaultPolicy2027BC9A", + "Roles": [ + { + "Ref": "codedeploygroupServiceRole1BD49E37", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, "kmskey49FBC3B3": { "DeletionPolicy": "Retain", "Properties": { @@ -530,16 +1465,7 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` }, "Type": "AWS::KMS::Alias", }, - "topic69831491": { - "Properties": { - "KmsMasterKeyId": { - "Fn::GetAtt": [ - "kmskey49FBC3B3", - "Arn", - ], - }, - "TopicName": "serviceName", - }, + "sourcetopic7C3DC892": { "Type": "AWS::SNS::Topic", }, "worker28EA3E30": { @@ -557,6 +1483,7 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` }, "S3Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.zip", }, + "Description": "Updated at 1212-12-12T12:12:12.121Z", "Environment": { "Variables": { "NODE_ENV": "production", @@ -579,9 +1506,82 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` ], }, "Runtime": "nodejs20.x", + "Timeout": 30, }, "Type": "AWS::Lambda::Function", }, + "workerAliaslive62FE5FAF": { + "Properties": { + "Description": "The Lambda version currently receiving traffic", + "FunctionName": { + "Ref": "worker28EA3E30", + }, + "FunctionVersion": { + "Fn::GetAtt": [ + "workerCurrentVersionFA5B4BF44d168cc709f38ecb408a0929304cea9d", + "Version", + ], + }, + "Name": "live", + }, + "Type": "AWS::Lambda::Alias", + "UpdatePolicy": { + "CodeDeployLambdaAliasUpdate": { + "AfterAllowTrafficHook": { + "Ref": "workerposthook150842D6", + }, + "ApplicationName": { + "Ref": "codedeployapplicationF49B9864", + }, + "BeforeAllowTrafficHook": { + "Ref": "workerprehook415B13CE", + }, + "DeploymentGroupName": { + "Ref": "codedeploygroup441B094B", + }, + }, + }, + }, + "workerAliasliveSqsEventSourceappStackworkerqueue8281B9F42DEDB47B": { + "Properties": { + "EventSourceArn": { + "Fn::GetAtt": [ + "workerqueueA05CE5C6", + "Arn", + ], + }, + "FunctionName": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 6, + { + "Fn::Split": [ + ":", + { + "Ref": "workerAliaslive62FE5FAF", + }, + ], + }, + ], + }, + ":live", + ], + ], + }, + }, + "Type": "AWS::Lambda::EventSourceMapping", + }, + "workerCurrentVersionFA5B4BF44d168cc709f38ecb408a0929304cea9d": { + "Properties": { + "FunctionName": { + "Ref": "worker28EA3E30", + }, + }, + "Type": "AWS::Lambda::Version", + }, "workerServiceRole2130CC7F": { "Properties": { "AssumeRolePolicyDocument": { @@ -655,19 +1655,305 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` }, "Type": "AWS::IAM::Policy", }, - "workerSqsEventSourceappStackworkerqueue8281B9F47B9F582B": { + "workerposthook150842D6": { + "DependsOn": [ + "workerposthookServiceRoleDefaultPolicy6A92F69C", + "workerposthookServiceRole25452300", + ], "Properties": { - "EventSourceArn": { + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.zip", + }, + "Environment": { + "Variables": { + "FUNCTION_NAME_TO_PRUNE": { + "Ref": "worker28EA3E30", + }, + "NODE_ENV": "production", + "NODE_OPTIONS": "--enable-source-maps", + "SOMETHING": "prod", + }, + }, + "FunctionName": "serviceName-post-hook", + "Handler": "index.handler", + "KmsKeyArn": { "Fn::GetAtt": [ - "workerqueueA05CE5C6", + "kmskey49FBC3B3", "Arn", ], }, - "FunctionName": { - "Ref": "worker28EA3E30", + "Role": { + "Fn::GetAtt": [ + "workerposthookServiceRole25452300", + "Arn", + ], }, + "Runtime": "nodejs20.x", + "Timeout": 30, }, - "Type": "AWS::Lambda::EventSourceMapping", + "Type": "AWS::Lambda::Function", + }, + "workerposthookServiceRole25452300": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "workerposthookServiceRoleDefaultPolicy6A92F69C": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "lambda:ListAliases", + "lambda:ListVersionsByFunction", + "lambda:DeleteFunction", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + { + "Action": "codedeploy:PutLifecycleEventHookExecutionStatus", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":codedeploy:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":deploymentgroup:", + { + "Ref": "codedeployapplicationF49B9864", + }, + "/", + { + "Ref": "codedeploygroup441B094B", + }, + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "workerposthookServiceRoleDefaultPolicy6A92F69C", + "Roles": [ + { + "Ref": "workerposthookServiceRole25452300", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "workerprehook415B13CE": { + "DependsOn": [ + "workerprehookServiceRoleDefaultPolicy991A21B9", + "workerprehookServiceRole8F8D9379", + ], + "Properties": { + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.zip", + }, + "Environment": { + "Variables": { + "FUNCTION_NAME_TO_INVOKE": { + "Ref": "worker28EA3E30", + }, + "NODE_ENV": "production", + "NODE_OPTIONS": "--enable-source-maps", + "SOMETHING": "prod", + }, + }, + "FunctionName": "serviceName-pre-hook", + "Handler": "index.handler", + "KmsKeyArn": { + "Fn::GetAtt": [ + "kmskey49FBC3B3", + "Arn", + ], + }, + "Role": { + "Fn::GetAtt": [ + "workerprehookServiceRole8F8D9379", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Timeout": 120, + }, + "Type": "AWS::Lambda::Function", + }, + "workerprehookServiceRole8F8D9379": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "workerprehookServiceRoleDefaultPolicy991A21B9": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "worker28EA3E30", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + { + "Action": "codedeploy:PutLifecycleEventHookExecutionStatus", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":codedeploy:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":deploymentgroup:", + { + "Ref": "codedeployapplicationF49B9864", + }, + "/", + { + "Ref": "codedeploygroup441B094B", + }, + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "workerprehookServiceRoleDefaultPolicy991A21B9", + "Roles": [ + { + "Ref": "workerprehookServiceRole8F8D9379", + }, + ], + }, + "Type": "AWS::IAM::Policy", }, "workerqueueA05CE5C6": { "DeletionPolicy": "Delete", @@ -701,7 +1987,7 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` "Condition": { "ArnEquals": { "aws:SourceArn": { - "Ref": "topic69831491", + "Ref": "sourcetopic7C3DC892", }, }, }, @@ -727,7 +2013,7 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` }, "Type": "AWS::SQS::QueuePolicy", }, - "workerqueueappStacktopic0CA45134AFB31FF4": { + "workerqueueappStacksourcetopic613C6BDBD2F224F5": { "DependsOn": [ "workerqueuePolicy97054CB4", ], @@ -740,7 +2026,7 @@ exports[`returns expected CloudFormation stack for prod 1`] = ` }, "Protocol": "sqs", "TopicArn": { - "Ref": "topic69831491", + "Ref": "sourcetopic7C3DC892", }, }, "Type": "AWS::SNS::Subscription", diff --git a/template/lambda-sqs-worker-cdk/infra/appStack.test.ts b/template/lambda-sqs-worker-cdk/infra/appStack.test.ts index 4e31029cf..e6229b20b 100644 --- a/template/lambda-sqs-worker-cdk/infra/appStack.test.ts +++ b/template/lambda-sqs-worker-cdk/infra/appStack.test.ts @@ -1,4 +1,4 @@ -import { App } from 'aws-cdk-lib'; +import { App, aws_sns } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import cdkJson from '../cdk.json'; @@ -17,9 +17,27 @@ const contexts = [ }, ]; +const currentDate = '1212-12-12T12:12:12.121Z'; + +jest.useFakeTimers({ + legacyFakeTimers: false, + doNotFake: [ + 'nextTick', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + now: new Date(currentDate), +}); + it.each(contexts)( 'returns expected CloudFormation stack for $stage', (context) => { + jest + .spyOn(aws_sns.Topic, 'fromTopicArn') + .mockImplementation((scope, id) => new aws_sns.Topic(scope, id)); + const app = new App({ context }); const stack = new AppStack(app, 'appStack'); diff --git a/template/lambda-sqs-worker-cdk/infra/appStack.ts b/template/lambda-sqs-worker-cdk/infra/appStack.ts index 6e9f76855..288d3abcc 100644 --- a/template/lambda-sqs-worker-cdk/infra/appStack.ts +++ b/template/lambda-sqs-worker-cdk/infra/appStack.ts @@ -1,6 +1,9 @@ import { + Duration, Stack, type StackProps, + aws_cloudwatch, + aws_codedeploy, aws_iam, aws_kms, aws_lambda, @@ -32,11 +35,6 @@ export class AppStack extends Stack { kmsKey.grantEncrypt(accountPrincipal); - const topic = new aws_sns.Topic(this, 'topic', { - topicName: '<%- serviceName %>', - masterKey: kmsKey, - }); - const deadLetterQueue = new aws_sqs.Queue(this, 'worker-queue-dlq', { queueName: '<%- serviceName %>-dlq', encryptionMasterKey: kmsKey, @@ -51,32 +49,134 @@ export class AppStack extends Stack { encryptionMasterKey: kmsKey, }); + const topic = aws_sns.Topic.fromTopicArn( + this, + 'source-topic', + context.sourceSnsTopicArn, + ); + + topic.addSubscription(new aws_sns_subscriptions.SqsSubscription(queue)); + const architecture = '<%- lambdaCdkArchitecture %>'; - const worker = new aws_lambda_nodejs.NodejsFunction(this, 'worker', { + const defaultWorkerConfig: aws_lambda_nodejs.NodejsFunctionProps = { architecture: aws_lambda.Architecture[architecture], - entry: './src/app.ts', - bundling: { - sourceMap: true, - target: 'node20', - // By default the aws-sdk-v3 is set as an external module, however, we want it to be bundled with the lambda - externalModules: [], - }, runtime: aws_lambda.Runtime.NODEJS_20_X, - functionName: '<%- serviceName %>', environmentEncryption: kmsKey, + // aws-sdk-v3 sets this to true by default, so it is not necessary to set the environment variable + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-reusing-connections.html + awsSdkConnectionReuse: false, + }; + + const defaultWorkerBundlingConfig: aws_lambda_nodejs.BundlingOptions = { + sourceMap: true, + target: 'node20', + // aws-sdk-v3 is set as an external module by default, but we want it to be bundled with the function + externalModules: [], + }; + + const defaultWorkerEnvironment: Record = { + NODE_ENV: 'production', + // https://nodejs.org/api/cli.html#cli_node_options_options + NODE_OPTIONS: '--enable-source-maps', + }; + + const worker = new aws_lambda_nodejs.NodejsFunction(this, 'worker', { + ...defaultWorkerConfig, + entry: './src/app.ts', + timeout: Duration.seconds(30), + bundling: defaultWorkerBundlingConfig, + functionName: '<%- serviceName %>', environment: { - NODE_ENV: 'production', - // https://nodejs.org/api/cli.html#cli_node_options_options - NODE_OPTIONS: '--enable-source-maps', + ...defaultWorkerEnvironment, ...context.workerLambda.environment, }, - // aws-sdk-v3 sets this to true by default so it is not necessary to set the environment variable - awsSdkConnectionReuse: false, + // https://github.com/aws/aws-cdk/issues/28237 + // This forces the lambda to be updated on every deployment + // If you do not wish to use hotswap, you can remove the new Date().toISOString() from the description + description: `Updated at ${new Date().toISOString()}`, }); - worker.addEventSource(new aws_lambda_event_sources.SqsEventSource(queue)); + const alias = worker.addAlias('live', { + description: 'The Lambda version currently receiving traffic', + }); - topic.addSubscription(new aws_sns_subscriptions.SqsSubscription(queue)); + alias.addEventSource(new aws_lambda_event_sources.SqsEventSource(queue)); + + const preHook = new aws_lambda_nodejs.NodejsFunction( + this, + 'worker-pre-hook', + { + ...defaultWorkerConfig, + entry: './src/preHook.ts', + timeout: Duration.seconds(120), + bundling: defaultWorkerBundlingConfig, + functionName: '<%- serviceName %>-pre-hook', + environment: { + ...defaultWorkerEnvironment, + ...context.workerLambda.environment, + FUNCTION_NAME_TO_INVOKE: worker.functionName, + }, + }, + ); + + worker.grantInvoke(preHook); + + const postHook = new aws_lambda_nodejs.NodejsFunction( + this, + 'worker-post-hook', + { + ...defaultWorkerConfig, + entry: './src/postHook.ts', + timeout: Duration.seconds(30), + bundling: defaultWorkerBundlingConfig, + functionName: '<%- serviceName %>-post-hook', + environment: { + ...defaultWorkerEnvironment, + ...context.workerLambda.environment, + FUNCTION_NAME_TO_PRUNE: worker.functionName, + }, + }, + ); + + const prunePermissions = new aws_iam.PolicyStatement({ + actions: [ + 'lambda:ListAliases', + 'lambda:ListVersionsByFunction', + 'lambda:DeleteFunction', + ], + resources: [worker.functionArn, `${worker.functionArn}:*`], + }); + + postHook.addToRolePolicy(prunePermissions); + + const application = new aws_codedeploy.LambdaApplication( + this, + 'codedeploy-application', + ); + + const deploymentGroup = new aws_codedeploy.LambdaDeploymentGroup( + this, + 'codedeploy-group', + { + application, + alias, + deploymentConfig: aws_codedeploy.LambdaDeploymentConfig.ALL_AT_ONCE, + }, + ); + + const alarm = new aws_cloudwatch.Alarm(this, 'codedeploy-alarm', { + metric: alias.metricErrors({ + period: Duration.seconds(60), + }), + threshold: 1, + evaluationPeriods: 1, + }); + + deploymentGroup.addAlarm(alarm); + + deploymentGroup.addPreHook(preHook); + + deploymentGroup.addPostHook(postHook); } } diff --git a/template/lambda-sqs-worker-cdk/package.json b/template/lambda-sqs-worker-cdk/package.json index 7f43b28a6..e0505f753 100644 --- a/template/lambda-sqs-worker-cdk/package.json +++ b/template/lambda-sqs-worker-cdk/package.json @@ -12,6 +12,9 @@ "test:watch": "skuba test --watch" }, "dependencies": { + "@aws-sdk/client-codedeploy": "^3.363.0", + "@aws-sdk/client-lambda": "^3.363.0", + "@aws-sdk/client-sns": "^3.363.0", "@seek/logger": "^6.0.0", "zod": "^3.19.1" }, diff --git a/template/lambda-sqs-worker-cdk/shared/context-types.ts b/template/lambda-sqs-worker-cdk/shared/context-types.ts index 6be09ddcb..1ae5f1d9e 100644 --- a/template/lambda-sqs-worker-cdk/shared/context-types.ts +++ b/template/lambda-sqs-worker-cdk/shared/context-types.ts @@ -10,6 +10,7 @@ export const EnvContextSchema = z.object({ SOMETHING: z.string(), }), }), + sourceSnsTopicArn: z.string(), }); export type EnvContext = z.infer; diff --git a/template/lambda-sqs-worker-cdk/src/app.ts b/template/lambda-sqs-worker-cdk/src/app.ts index bf1dc064e..60f069486 100644 --- a/template/lambda-sqs-worker-cdk/src/app.ts +++ b/template/lambda-sqs-worker-cdk/src/app.ts @@ -5,6 +5,19 @@ const logger = createLogger({ name: '<%- serviceName %>', }); -export const handler: SQSHandler = (_: SQSEvent) => { +/** + * Tests connectivity to ensure appropriate access and network configuration. + */ +const smokeTest = async () => Promise.resolve(); + +export const handler: SQSHandler = (event: SQSEvent) => { + // Treat an empty object as our smoke test event. + if (!Object.keys(event).length) { + logger.debug('Received smoke test request'); + return smokeTest(); + } + logger.info('Hello World!'); + + return; }; diff --git a/template/lambda-sqs-worker-cdk/src/postHook.ts b/template/lambda-sqs-worker-cdk/src/postHook.ts new file mode 100644 index 000000000..03a28ae63 --- /dev/null +++ b/template/lambda-sqs-worker-cdk/src/postHook.ts @@ -0,0 +1,154 @@ +/* eslint-disable no-console */ +/* istanbul ignore file */ + +import { + CodeDeploy, + PutLifecycleEventHookExecutionStatusCommand, +} from '@aws-sdk/client-codedeploy'; +import { + type AliasConfiguration, + DeleteFunctionCommand, + type FunctionConfiguration, + LambdaClient, + ListAliasesCommand, + ListVersionsByFunctionCommand, +} from '@aws-sdk/client-lambda'; +import { z } from 'zod'; + +const lambda = new LambdaClient(); +const codeDeploy = new CodeDeploy(); + +const listLambdaVersions = async ( + functionName: string, + marker?: string, +): Promise => { + const result = await lambda.send( + new ListVersionsByFunctionCommand({ + FunctionName: functionName, + Marker: marker, + }), + ); + const versions = result.Versions ?? []; + if (result.NextMarker) { + return [ + ...versions, + ...(await listLambdaVersions(functionName, result.NextMarker)), + ]; + } + + return versions; +}; + +const listAliases = async ( + functionName: string, + marker?: string, +): Promise => { + const result = await lambda.send( + new ListAliasesCommand({ + FunctionName: functionName, + Marker: marker, + }), + ); + const aliases = result.Aliases ?? []; + if (result.NextMarker) { + return [ + ...aliases, + ...(await listAliases(functionName, result.NextMarker)), + ]; + } + + return aliases; +}; + +const pruneLambdas = async ( + functionName: string, + numberToKeep: number, +): Promise => { + const [aliases, versions] = await Promise.all([ + listAliases(functionName), + listLambdaVersions(functionName), + ]); + + const aliasMap = new Map( + aliases.flatMap((alias) => + alias.FunctionVersion ? [[alias.FunctionVersion, alias]] : [], + ), + ); + + const versionsToPrune = versions + .filter( + (version) => + version.Version && + !aliasMap.has(version.Version) && + version.Version !== '$LATEST', + ) + .sort((a, b) => Number(b.Version) - Number(a.Version)) + .slice(numberToKeep); + + if (!versionsToPrune.length) { + console.log('No function versions to prune'); + return; + } + + console.log( + `Pruning function versions: ${versionsToPrune + .map((version) => version.Version) + .join(', ')}`, + ); + + await Promise.all( + versionsToPrune.map((version) => + lambda.send( + new DeleteFunctionCommand({ + FunctionName: version.FunctionName, + Qualifier: version.Version, + }), + ), + ), + ); +}; + +const EnvSchema = z.object({ + FUNCTION_NAME_TO_PRUNE: z.string(), + NUMBER_OF_VERSIONS_TO_KEEP: z.coerce.number().default(0), +}); + +type Status = 'Succeeded' | 'Failed'; + +/** + * The event supplied to a CodeDeploy lifecycle hook Lambda function. + * + * {@link https://docs.aws.amazon.com/codedeploy/latest/userguide/tutorial-ecs-with-hooks-create-hooks.html} + */ +interface CodeDeployLifecycleHookEvent { + DeploymentId: string; + LifecycleEventHookExecutionId: string; +} + +/** + * A handler to clean up old Lambda function versions and layers + */ +export const handler = async ( + event: CodeDeployLifecycleHookEvent, +): Promise => { + let status: Status = 'Succeeded'; + try { + const { + FUNCTION_NAME_TO_PRUNE: functionName, + NUMBER_OF_VERSIONS_TO_KEEP: numberToKeep, + } = EnvSchema.parse(process.env); + + await pruneLambdas(functionName, numberToKeep); + } catch (err) { + console.error('Exception:', err); + status = 'Failed'; + } + + await codeDeploy.send( + new PutLifecycleEventHookExecutionStatusCommand({ + deploymentId: event.DeploymentId, + lifecycleEventHookExecutionId: event.LifecycleEventHookExecutionId, + status, + }), + ); +}; diff --git a/template/lambda-sqs-worker-cdk/src/preHook.ts b/template/lambda-sqs-worker-cdk/src/preHook.ts new file mode 100644 index 000000000..c892f7fa1 --- /dev/null +++ b/template/lambda-sqs-worker-cdk/src/preHook.ts @@ -0,0 +1,95 @@ +/* eslint-disable no-console */ +/* istanbul ignore file */ + +// Use minimal dependencies to reduce the chance of crashes on module load. +import { + CodeDeployClient, + PutLifecycleEventHookExecutionStatusCommand, +} from '@aws-sdk/client-codedeploy'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; + +const codeDeploy = new CodeDeployClient({ + apiVersion: '2014-10-06', + maxAttempts: 5, +}); + +const lambda = new LambdaClient({ + apiVersion: '2015-03-31', + maxAttempts: 5, +}); + +type Status = 'Succeeded' | 'Failed'; + +/** + * Synchronously invokes a Lambda function with a smoke test event. + * + * Any non-error response is treated as a success. + */ +const smokeTestLambdaFunction = async (): Promise => { + const functionName = process.env.FUNCTION_NAME_TO_INVOKE; + + if (!functionName) { + console.error('Missing process.env.FUNCTION_NAME_TO_INVOKE'); + return 'Failed'; + } + + console.info('Function:', functionName); + + const response = await lambda.send( + new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + // Treat an empty object as our smoke test event. + Payload: Buffer.from('{}'), + }), + ); + + console.info('Version:', response.ExecutedVersion ?? '?'); + console.info('Status', response.StatusCode ?? '?'); + + if (response.FunctionError) { + console.error('Error:', response.FunctionError); + if (response.Payload) { + console.error(response.Payload.transformToString()); + } + return 'Failed'; + } + + return response.StatusCode === 200 ? 'Succeeded' : 'Failed'; +}; + +/** + * The event supplied to a CodeDeploy lifecycle hook Lambda function. + * + * {@link https://docs.aws.amazon.com/codedeploy/latest/userguide/tutorial-ecs-with-hooks-create-hooks.html} + */ +interface CodeDeployLifecycleHookEvent { + DeploymentId: string; + LifecycleEventHookExecutionId: string; +} + +/** + * A handler to smoke test a new Lambda function version before it goes live. + * + * This tries to be exception safe so that a status reaches CodeDeploy. If we + * crash or otherwise fail to report back, the deployment will hang for an hour. + */ +export const handler = async ( + event: CodeDeployLifecycleHookEvent, +): Promise => { + let status: Status; + try { + status = await smokeTestLambdaFunction(); + } catch (err) { + console.error('Exception:', err); + status = 'Failed'; + } + + await codeDeploy.send( + new PutLifecycleEventHookExecutionStatusCommand({ + deploymentId: event.DeploymentId, + lifecycleEventHookExecutionId: event.LifecycleEventHookExecutionId, + status, + }), + ); +};