diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-s3-deployment/test/integ.bucket-deployment-jsondata-findinmap.ts b/packages/@aws-cdk-testing/framework-integ/test/aws-s3-deployment/test/integ.bucket-deployment-jsondata-findinmap.ts new file mode 100644 index 0000000000000..ed028b3c08c92 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-s3-deployment/test/integ.bucket-deployment-jsondata-findinmap.ts @@ -0,0 +1,41 @@ +import * as cdk from 'aws-cdk-lib'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'; +import * as integ from '@aws-cdk/integ-tests-alpha'; +import { Fn, CfnMapping } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; + +class FindInMapSourceJsonTestStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const bucket = new s3.Bucket(this, 'TestBucket', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + + new CfnMapping(this, 'ResponseMap', { + mapping: { + DefaultResponse: { + Message: 'Hello from mapping!', + }, + }, + }); + + new s3deploy.BucketDeployment(this, 'DeployWithFindInMap', { + sources: [ + s3deploy.Source.jsonData('config.json', { + message: Fn.findInMap('ResponseMap', 'DefaultResponse', 'Message'), + }), + ], + destinationBucket: bucket, + retainOnDelete: false, + }); + } +} + +const app = new cdk.App(); +new integ.IntegTest(app, 'integ-findinmap-source-json', { + testCases: [new FindInMapSourceJsonTestStack(app, 'FindInMapTest')], + diffAssets: true, +}); diff --git a/packages/aws-cdk-lib/aws-s3-deployment/README.md b/packages/aws-cdk-lib/aws-s3-deployment/README.md index fd4b28dbfcb0c..1901c576a5fb5 100644 --- a/packages/aws-cdk-lib/aws-s3-deployment/README.md +++ b/packages/aws-cdk-lib/aws-s3-deployment/README.md @@ -382,8 +382,7 @@ new s3deploy.BucketDeployment(this, 'DeployMeWithEfsStorage', { ## Data with deploy-time values The content passed to `Source.data()`, `Source.jsonData()`, or `Source.yamlData()` can include -references that will get resolved only during deployment. Only a subset of CloudFormation functions -are supported however, namely: Ref, Fn::GetAtt, Fn::Join, and Fn::Select (Fn::Split may be nested under Fn::Select). +references that will get resolved only during deployment. Only a subset of CloudFormation functions are supported however, namely: `Ref`, `Fn::GetAtt`, `Fn::Join`, `Fn::Select`, and `Fn::FindInMap` (Fn::Split may be nested under Fn::Select). For example: diff --git a/packages/aws-cdk-lib/aws-s3-deployment/lib/render-data.ts b/packages/aws-cdk-lib/aws-s3-deployment/lib/render-data.ts index 7d9979a9f4cf5..ee87b16da7713 100644 --- a/packages/aws-cdk-lib/aws-s3-deployment/lib/render-data.ts +++ b/packages/aws-cdk-lib/aws-s3-deployment/lib/render-data.ts @@ -58,9 +58,9 @@ export function renderData(scope: Construct, data: string): Content { throw new ValidationError('Unexpected: Expecting `resolve()` to return "Fn::Join", "Ref" or "Fn::GetAtt"', scope); } - function addMarker(part: Ref | GetAtt | FnSelect) { + function addMarker(part: Ref | GetAtt | FnSelect |FnFindInMap) { const keys = Object.keys(part); - const acceptedCfnFns = ['Ref', 'Fn::GetAtt', 'Fn::Select']; + const acceptedCfnFns = ['Ref', 'Fn::GetAtt', 'Fn::Select', 'Fn::FindInMap']; if (keys.length !== 1 || !acceptedCfnFns.includes(keys[0])) { const stringifiedAcceptedCfnFns = acceptedCfnFns.map((fn) => `"${fn}"`).join(' or '); throw new ValidationError(`Invalid CloudFormation reference. Key must start with any of ${stringifiedAcceptedCfnFns}. Got ${JSON.stringify(part)}`, scope); @@ -75,8 +75,9 @@ export function renderData(scope: Construct, data: string): Content { } type FnJoin = [string, FnJoinPart[]]; -type FnJoinPart = string | Ref | GetAtt | FnSelect; +type FnJoinPart = string | Ref | GetAtt | FnSelect | FnFindInMap; type Ref = { Ref: string }; type GetAtt = { 'Fn::GetAtt': [string, string] }; type FnSplit = { 'Fn::Split': [string, string | Ref] }; type FnSelect = { 'Fn::Select': [number, string[] | FnSplit] }; +type FnFindInMap = { 'Fn::FindInMap': [string, string, string] }; diff --git a/packages/aws-cdk-lib/aws-s3-deployment/test/__snapshots__/integ-findinMap.test.ts.snap b/packages/aws-cdk-lib/aws-s3-deployment/test/__snapshots__/integ-findinMap.test.ts.snap new file mode 100644 index 0000000000000..1af94fb1fec4c --- /dev/null +++ b/packages/aws-cdk-lib/aws-s3-deployment/test/__snapshots__/integ-findinMap.test.ts.snap @@ -0,0 +1,270 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BucketDeployment with Fn::FindInMap 1`] = ` +{ + "Mappings": { + "ResponseMap": { + "DefaultResponse": { + "Message": "Hello from mapping!", + }, + }, + }, + "Parameters": { + "BootstrapVersion": { + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "Bucket83908E77": { + "DeletionPolicy": "Retain", + "Properties": { + "Tags": [ + { + "Key": "aws-cdk:cr-owned:fdff45d8", + "Value": "true", + }, + ], + }, + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536": { + "DependsOn": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF", + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "4fe0aba5e672b596d0f72505a9eec502f98d46906bb30fae2511fbdc1df4956f.zip", + }, + "Environment": { + "Variables": { + "AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + }, + }, + "Handler": "index.handler", + "Layers": [ + { + "Ref": "DeployWithMappingAwsCliLayer57BB3E00", + }, + ], + "Role": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265", + "Arn", + ], + }, + "Runtime": "python3.11", + "Timeout": 900, + }, + "Type": "AWS::Lambda::Function", + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265": { + "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", + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "/*", + ], + ], + }, + ], + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "Bucket83908E77", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "Bucket83908E77", + "Arn", + ], + }, + "/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF", + "Roles": [ + { + "Ref": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "DeployWithMappingAwsCliLayer57BB3E00": { + "Properties": { + "Content": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "1c5a2ab1d1e53b0672a644454aab3dbb258ccd0079c92ad0e23b95b2c2079f70.zip", + }, + "Description": "/opt/awscli/aws", + }, + "Type": "AWS::Lambda::LayerVersion", + }, + "DeployWithMappingCustomResourceAAB44288": { + "DeletionPolicy": "Delete", + "Properties": { + "DestinationBucketName": { + "Ref": "Bucket83908E77", + }, + "OutputObjectKeys": true, + "Prune": true, + "ServiceToken": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536", + "Arn", + ], + }, + "SourceBucketNames": [ + { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + ], + "SourceMarkers": [ + { + "<>": { + "Fn::FindInMap": [ + "ResponseMap", + "DefaultResponse", + "Message", + ], + }, + }, + ], + "SourceObjectKeys": [ + "3ab13a2079a9a1da38ff22ba8d4decb877016eb3de170fe21d30fcd62c41b729.zip", + ], + }, + "Type": "Custom::CDKBucketDeployment", + "UpdateReplacePolicy": "Delete", + }, + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5", + ], + { + "Ref": "BootstrapVersion", + }, + ], + }, + ], + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, +} +`; diff --git a/packages/aws-cdk-lib/aws-s3-deployment/test/content.test.ts b/packages/aws-cdk-lib/aws-s3-deployment/test/content.test.ts index 2f1c6d1ddf542..82a90a0114406 100644 --- a/packages/aws-cdk-lib/aws-s3-deployment/test/content.test.ts +++ b/packages/aws-cdk-lib/aws-s3-deployment/test/content.test.ts @@ -1,8 +1,9 @@ import { Vpc } from '../../aws-ec2'; import * as elbv2 from '../../aws-elasticloadbalancingv2'; +import * as iam from '../../aws-iam'; import * as lambda from '../../aws-lambda'; import * as s3 from '../../aws-s3'; -import { Lazy, Stack } from '../../core'; +import { Lazy, Stack, Fn, CfnMapping } from '../../core'; import { Source } from '../lib'; import { renderData } from '../lib/render-data'; @@ -154,3 +155,58 @@ test('lazy string which resolves to something with a deploy-time value', () => { markers: { }, }); }); + +test('supports Fn::FindInMap in deploy-time json data', () => { + const stack = new Stack(); + + const mapping = new CfnMapping(stack, 'TestMapping', { + mapping: { + responseTemplate: { full: 'example value' }, + }, + }); + + const source = Source.jsonData('file.json', { + key: Fn.findInMap('TestMapping', 'responseTemplate', 'full'), + }); + + expect(() => + source.bind(stack, { + handlerRole: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + }), + }), + ).not.toThrow(); +}); + +test('renderData returns plain string untouched', () => { + const stack = new Stack(); + const input = 'plain-string'; + const result = renderData(stack, input); + expect(result.text).toBe('plain-string'); + expect(result.markers).toEqual({}); +}); + +test('renderData parses Fn::Join with string and Ref parts', () => { + const stack = new Stack(); + const input = { + 'Fn::Join': ['', [ + 'prefix-', + { Ref: 'MyParam' }, + '-suffix' + ]] + }; + const result = renderData(stack, input as any); + expect(result.text).toContain('prefix-'); + expect(result.text).toContain('-suffix'); + expect(Object.values(result.markers)[0]).toEqual({ Ref: 'MyParam' }); +}); + +test('throws on invalid marker object key', () => { + const stack = new Stack(); + const input = { + 'Fn::Join': ['', [ + { 'Fn::Wrong': ['oops'] } + ]] + }; + expect(() => renderData(stack, input as any)).toThrow(/Invalid CloudFormation reference/); +}); \ No newline at end of file