From 875d963f1373e77741386c654befeae8b038b150 Mon Sep 17 00:00:00 2001 From: Jorge Azevedo Date: Wed, 4 Mar 2026 16:41:08 +0000 Subject: [PATCH] fix: sanitise schedule expression in GuScheduledLambda construct IDs Schedule expressions like cron(* * * * ? *) contain characters that produce unstable CloudFormation logical IDs. Strip non-alphanumeric characters from the expression before using it in the construct ID. --- .vscode/settings.json | 7 +- .../scheduled-lambda.test.ts.snap | 310 +++++++++++++++++- src/patterns/scheduled-lambda.test.ts | 27 ++ src/patterns/scheduled-lambda.ts | 9 +- 4 files changed, 341 insertions(+), 12 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 05cf3b9e03..0953408348 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,12 @@ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "files.watcherExclude": { "**/target": true + }, + "debug.javascript.defaultRuntimeExecutable": { + "pwa-node": "/Users/jorge_azevedo/.local/share/mise/shims/node" } -} +} \ No newline at end of file diff --git a/src/patterns/__snapshots__/scheduled-lambda.test.ts.snap b/src/patterns/__snapshots__/scheduled-lambda.test.ts.snap index 70041c69f1..e2860b6080 100644 --- a/src/patterns/__snapshots__/scheduled-lambda.test.ts.snap +++ b/src/patterns/__snapshots__/scheduled-lambda.test.ts.snap @@ -229,7 +229,26 @@ exports[`The GuScheduledLambda pattern should create the correct resources with }, "Type": "AWS::IAM::Role", }, - "mylambdafunctionmylambdafunctionrate1minute06AD0015D": { + "mylambdafunctionmylambdafunctionrate1minute0AllowEventRuleTestmylambdafunction4858F7ED96C9ADFB": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "mylambdafunction8D341B54", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "mylambdafunctionmylambdafunctionrate1minute0FBE73443", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "mylambdafunctionmylambdafunctionrate1minute0FBE73443": { "Properties": { "ScheduleExpression": "rate(1 minute)", "State": "ENABLED", @@ -270,7 +289,240 @@ exports[`The GuScheduledLambda pattern should create the correct resources with }, "Type": "AWS::Events::Rule", }, - "mylambdafunctionmylambdafunctionrate1minute0AllowEventRuleTestmylambdafunction4858F7ED96852F99": { + }, +} +`; + +exports[`The GuScheduledLambda pattern should create the correct resources with minimal config 1`] = ` +{ + "Metadata": { + "gu:cdk:constructs": [ + "GuStack", + "GuDistributionBucketParameter", + "GuScheduledLambda", + ], + "gu:cdk:version": "TEST", + }, + "Parameters": { + "DistributionBucketName": { + "Default": "/account/services/artifact.bucket", + "Description": "SSM parameter containing the S3 bucket name holding distribution artifacts", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "mylambdafunction8D341B54": { + "DependsOn": [ + "mylambdafunctionServiceRoleDefaultPolicy769897D4", + "mylambdafunctionServiceRoleE82C2E25", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "DistributionBucketName", + }, + "S3Key": "test-stack/TEST/testing/lambda.zip", + }, + "Environment": { + "Variables": { + "APP": "testing", + "STACK": "test-stack", + "STAGE": "TEST", + }, + }, + "FunctionName": "my-lambda-function", + "Handler": "my-lambda/handler", + "LoggingConfig": { + "LogFormat": "JSON", + }, + "MemorySize": 512, + "Role": { + "Fn::GetAtt": [ + "mylambdafunctionServiceRoleE82C2E25", + "Arn", + ], + }, + "Runtime": "nodejs12.x", + "Tags": [ + { + "Key": "App", + "Value": "testing", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "mylambdafunctionServiceRoleDefaultPolicy769897D4": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Ref": "DistributionBucketName", + }, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Ref": "DistributionBucketName", + }, + "/test-stack/TEST/testing/lambda.zip", + ], + ], + }, + ], + }, + { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:ssm:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing", + ], + ], + }, + }, + { + "Action": [ + "ssm:GetParameters", + "ssm:GetParameter", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:ssm:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "mylambdafunctionServiceRoleDefaultPolicy769897D4", + "Roles": [ + { + "Ref": "mylambdafunctionServiceRoleE82C2E25", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "mylambdafunctionServiceRoleE82C2E25": { + "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", + ], + ], + }, + ], + "Tags": [ + { + "Key": "App", + "Value": "testing", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "mylambdafunctionmylambdafunctionrate1minute0AllowEventRuleTestmylambdafunction4858F7ED96C9ADFB": { "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { @@ -282,18 +534,58 @@ exports[`The GuScheduledLambda pattern should create the correct resources with "Principal": "events.amazonaws.com", "SourceArn": { "Fn::GetAtt": [ - "mylambdafunctionmylambdafunctionrate1minute06AD0015D", + "mylambdafunctionmylambdafunctionrate1minute0FBE73443", "Arn", ], }, }, "Type": "AWS::Lambda::Permission", }, + "mylambdafunctionmylambdafunctionrate1minute0FBE73443": { + "Properties": { + "ScheduleExpression": "rate(1 minute)", + "State": "ENABLED", + "Tags": [ + { + "Key": "App", + "Value": "testing", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "mylambdafunction8D341B54", + "Arn", + ], + }, + "Id": "Target0", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, }, } `; -exports[`The GuScheduledLambda pattern should create the correct resources with minimal config 1`] = ` +exports[`The GuScheduledLambda pattern should produce stable resource IDs when using a cron schedule 1`] = ` { "Metadata": { "gu:cdk:constructs": [ @@ -342,7 +634,7 @@ exports[`The GuScheduledLambda pattern should create the correct resources with "Arn", ], }, - "Runtime": "nodejs12.x", + "Runtime": "nodejs20.x", "Tags": [ { "Key": "App", @@ -522,9 +814,9 @@ exports[`The GuScheduledLambda pattern should create the correct resources with }, "Type": "AWS::IAM::Role", }, - "mylambdafunctionmylambdafunctionrate1minute06AD0015D": { + "mylambdafunctionmylambdafunctioncron0250468E7": { "Properties": { - "ScheduleExpression": "rate(1 minute)", + "ScheduleExpression": "cron(* * * * ? *)", "State": "ENABLED", "Tags": [ { @@ -562,7 +854,7 @@ exports[`The GuScheduledLambda pattern should create the correct resources with }, "Type": "AWS::Events::Rule", }, - "mylambdafunctionmylambdafunctionrate1minute0AllowEventRuleTestmylambdafunction4858F7ED96852F99": { + "mylambdafunctionmylambdafunctioncron0AllowEventRuleTestmylambdafunction4858F7EDCA889637": { "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { @@ -574,7 +866,7 @@ exports[`The GuScheduledLambda pattern should create the correct resources with "Principal": "events.amazonaws.com", "SourceArn": { "Fn::GetAtt": [ - "mylambdafunctionmylambdafunctionrate1minute06AD0015D", + "mylambdafunctionmylambdafunctioncron0250468E7", "Arn", ], }, diff --git a/src/patterns/scheduled-lambda.test.ts b/src/patterns/scheduled-lambda.test.ts index 26cc27bf15..fcf6e2fbcd 100644 --- a/src/patterns/scheduled-lambda.test.ts +++ b/src/patterns/scheduled-lambda.test.ts @@ -86,6 +86,33 @@ describe("The GuScheduledLambda pattern", () => { }); }); + it("should produce stable resource IDs when using a cron schedule", () => { + const stack = simpleGuStackForTesting(); + const noMonitoring: NoMonitoring = { noMonitoring: true }; + const props = { + fileName: "lambda.zip", + functionName: "my-lambda-function", + handler: "my-lambda/handler", + runtime: Runtime.NODEJS_20_X, + rules: [{ schedule: Schedule.expression("cron(* * * * ? *)") }], + monitoringConfiguration: noMonitoring, + app: "testing", + }; + new GuScheduledLambda(stack, "my-lambda-function", props); + const template = Template.fromStack(stack); + const resources = template.toJSON().Resources as Record; + const ruleIds = Object.keys(resources).filter((id) => { + return resources[id]?.Type === "AWS::Events::Rule"; + }); + + // Resource IDs must not contain characters from cron expressions such as ( ) * ? + for (const id of ruleIds) { + expect(id).not.toMatch(/[^a-zA-Z0-9]/); + } + + expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); + }); + it("should create the correct resources with an input in the rule", () => { const stack = simpleGuStackForTesting(); const noMonitoring: NoMonitoring = { noMonitoring: true }; diff --git a/src/patterns/scheduled-lambda.ts b/src/patterns/scheduled-lambda.ts index 6ea2b3fa81..240c350426 100644 --- a/src/patterns/scheduled-lambda.ts +++ b/src/patterns/scheduled-lambda.ts @@ -52,7 +52,14 @@ export class GuScheduledLambda extends GuLambdaFunction { // If we have an alias, use this to ensure that all invocations are handled by a published Lambda version. // Otherwise, use the latest unpublished version ($LATEST) const resourceToInvoke = this.alias ?? this; - new Rule(this, `${id}-${rule.schedule.expressionString}-${index}`, { + + // Sanitize the expression string for use in a construct ID. + // Schedule expressions like `cron(* * * * ? *)` contain characters + // that are invalid in CloudFormation logical IDs and lead to + // unstable resource identifiers across deployments. + const sanitisedExpression = rule.schedule.expressionString.replace(/[^a-zA-Z0-9-]/g, ""); + + new Rule(this, `${id}-${sanitisedExpression}-${index}`, { schedule: rule.schedule, targets: [new LambdaFunction(resourceToInvoke, { event: rule.input })], ...(rule.description && { description: rule.description }),