diff --git a/integration/apigateway/index.ts b/integration/apigateway/index.ts index 23211007..fd16f10a 100644 --- a/integration/apigateway/index.ts +++ b/integration/apigateway/index.ts @@ -9,8 +9,7 @@ class ApiGatewayStack extends pulumicdk.Stack { this.node.setContext('@aws-cdk/aws-apigateway:disableCloudWatchRole', 'true'); new RestApi(this, 'test-api'); - // TODO[pulumi/pulumi-cdk#187] - // new SfnApi(this, 'test-sfn-api'); + new SfnApi(this, 'test-sfn-api'); new SpecRestApi(this, 'test-spec-api'); } } diff --git a/integration/apigateway/sfn-api.ts b/integration/apigateway/sfn-api.ts index fd2d2019..58ec2180 100644 --- a/integration/apigateway/sfn-api.ts +++ b/integration/apigateway/sfn-api.ts @@ -1,4 +1,4 @@ -import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; @@ -15,8 +15,13 @@ export class SfnApi extends Construct { stateMachineType: sfn.StateMachineType.EXPRESS, }); + // TODO[pulumi/pulumi-cdk#62] The auto created role has too long name + const role = new iam.Role(this, 'StartRole', { + assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), + }); const api = new apigw.StepFunctionsRestApi(this, 'StepFunctionsRestApi', { deploy: false, + role, stateMachine: stateMachine, headers: true, path: false, diff --git a/integration/custom-resource/index.ts b/integration/custom-resource/index.ts index 19b26f2f..95ed6084 100644 --- a/integration/custom-resource/index.ts +++ b/integration/custom-resource/index.ts @@ -26,7 +26,6 @@ class S3DeploymentStack extends pulumicdk.Stack { autoDeleteObjects: true, }); - this.bucketWebsiteUrl = this.asOutput(bucket.bucketWebsiteUrl); const deploy = new s3deploy.BucketDeployment(this, 'DeployWebsite', { @@ -41,7 +40,7 @@ class S3DeploymentStack extends pulumicdk.Stack { new iam.Role(this, 'CustomResourceRole', { assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), inlinePolicies: { - 'CustomResourcePolicy': new iam.PolicyDocument({ + CustomResourcePolicy: new iam.PolicyDocument({ statements: [ new iam.PolicyStatement({ actions: ['s3:GetObject'], @@ -49,29 +48,18 @@ class S3DeploymentStack extends pulumicdk.Stack { }), ], }), - } + }, }); } } -const cfg = new pulumi.Config(); -const accountId = cfg.require('accountId'); - class MyApp extends pulumicdk.App { constructor() { super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { - const stack = new S3DeploymentStack(scope, 's3deployment', { - // configure the environment to prevent the bucket from using the unsupported FindInMap intrinsic (TODO[pulumi/pulumi-cdk#187]) - props: { - env: { - account: accountId, - region: process.env.AWS_REGION, - } - } - }); + const stack = new S3DeploymentStack(scope, 's3deployment'); return { bucketWebsiteUrl: stack.bucketWebsiteUrl, - bucketObjectKeys: stack.bucketObjectKeys + bucketObjectKeys: stack.bucketObjectKeys, }; }); } diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go index 464c23e8..d6be63fe 100644 --- a/integration/examples_nodejs_test.go +++ b/integration/examples_nodejs_test.go @@ -20,11 +20,8 @@ import ( "testing" "time" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/sts" "github.com/pulumi/pulumi/pkg/v3/testing/integration" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestApiGateway(t *testing.T) { @@ -140,21 +137,9 @@ func TestErrors(t *testing.T) { // S3 bucket on delete and another for uploading the index.html file for a static website to the bucket. // The test validates that the website is deployed, displays the expected content and gets cleaned up on delete. func TestCustomResource(t *testing.T) { - sess := session.Must(session.NewSessionWithOptions(session.Options{ - SharedConfigState: session.SharedConfigEnable, - })) - svc := sts.New(sess) - - result, err := svc.GetCallerIdentity(&sts.GetCallerIdentityInput{}) - require.NoError(t, err, "Failed to get AWS account ID") - accountId := *result.Account - test := getJSBaseOptions(t). With(integration.ProgramTestOptions{ Dir: filepath.Join(getCwd(t), "custom-resource"), - Config: map[string]string{ - "accountId": accountId, - }, // Workaround until TODO[pulumi/pulumi-aws-native#1816] is resolved. Env: []string{"PULUMI_CDK_EXPERIMENTAL_MAX_NAME_LENGTH=56"}, ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) { diff --git a/integration/misc-services/index.ts b/integration/misc-services/index.ts index d18f4c33..5b87301c 100644 --- a/integration/misc-services/index.ts +++ b/integration/misc-services/index.ts @@ -82,6 +82,14 @@ class MiscServicesStack extends pulumicdk.Stack { }), ], }); + + new lambda.Function(this, 'FindInMapFunc', { + runtime: lambda.Runtime.NODEJS_LATEST, + handler: 'index.handler', + code: lambda.Code.fromInline('def handler(event, context): return {}'), + // this adds a Fn::FindInMap + insightsVersion: lambda.LambdaInsightsVersion.VERSION_1_0_317_0, + }); } } diff --git a/src/assembly/stack.ts b/src/assembly/stack.ts index 9c91fc00..f3e55388 100644 --- a/src/assembly/stack.ts +++ b/src/assembly/stack.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { DestinationIdentifier, FileManifestEntry } from 'cdk-assets'; -import { CloudFormationParameter, CloudFormationResource, CloudFormationTemplate } from '../cfn'; +import { CloudFormationMapping, CloudFormationParameter, CloudFormationResource, CloudFormationTemplate } from '../cfn'; import { ConstructTree, StackMetadata } from './types'; import { FileAssetPackaging, FileDestination } from 'aws-cdk-lib/cloud-assembly-schema'; @@ -109,6 +109,12 @@ export class StackManifest { */ private readonly resources: { [logicalId: string]: CloudFormationResource }; + /** + * The Mappings from the CFN Stack + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html + */ + public readonly mappings?: CloudFormationMapping; + /** * */ @@ -118,6 +124,7 @@ export class StackManifest { this.dependencies = props.dependencies; this.outputs = props.template.Outputs; this.parameters = props.template.Parameters; + this.mappings = props.template.Mappings; this.metadata = props.metadata; this.templatePath = props.templatePath; this.id = props.id; diff --git a/src/cfn.ts b/src/cfn.ts index de5b7270..e7eb61ae 100644 --- a/src/cfn.ts +++ b/src/cfn.ts @@ -24,10 +24,16 @@ export interface CloudFormationResource { readonly DependsOn?: string | string[]; } +export type CloudFormationMapping = { [mappingLogicalName: string]: TopLevelMapping }; +export type CloudFormationMappingValue = string | string[]; +export type TopLevelMapping = { [key: string]: SecondLevelMapping }; +export type SecondLevelMapping = { [key: string]: CloudFormationMappingValue }; + export interface CloudFormationTemplate { Parameters?: { [id: string]: CloudFormationParameter }; Resources?: { [id: string]: CloudFormationResource }; Conditions?: { [id: string]: any }; + Mappings?: CloudFormationMapping; Outputs?: { [id: string]: any }; } diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index 83b2fc2f..fff20753 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -1,5 +1,5 @@ import * as cdk from 'aws-cdk-lib/core'; -import * as aws from '@pulumi/aws-native' +import * as aws from '@pulumi/aws-native'; import * as pulumi from '@pulumi/pulumi'; import { AssemblyManifestReader, StackManifest } from '../assembly'; import { ConstructInfo, Graph, GraphBuilder, GraphNode } from '../graph'; @@ -18,7 +18,7 @@ import { } from '@pulumi/aws-native'; import { mapToAwsResource } from '../aws-resource-mappings'; import { attributePropertyName, mapToCfnResource } from '../cfn-resource-mappings'; -import { CloudFormationResource, getDependsOn } from '../cfn'; +import { CloudFormationMapping, CloudFormationResource, getDependsOn } from '../cfn'; import { OutputMap, OutputRepr } from '../output-map'; import { parseSub } from '../sub'; import { getPartition } from '@pulumi/aws-native/getPartition'; @@ -470,6 +470,43 @@ export class StackConverter extends ArtifactConverter { throw new Error(`Fn::ImportValue is not yet supported.`); } + case 'Fn::FindInMap': { + return lift(([mappingLogicalName, topLevelKey, secondLevelKey]) => { + if (params.length !== 3) { + throw new Error(`Fn::FindInMap requires exactly 3 parameters, got ${params.length}`); + } + if (!this.stack.mappings) { + throw new Error(`No mappings found in stack`); + } + if (!(mappingLogicalName in this.stack.mappings)) { + throw new Error( + `Mapping ${mappingLogicalName} not found in mappings. Available mappings are ${Object.keys( + this.stack.mappings, + )}`, + ); + } + const topLevelMapping = this.stack.mappings[mappingLogicalName]; + if (!(topLevelKey in topLevelMapping)) { + throw new Error( + `Key ${topLevelKey} not found in mapping ${mappingLogicalName}. Available keys are ${Object.keys( + topLevelMapping, + )}`, + ); + } + const secondLevelMapping = topLevelMapping[topLevelKey]; + if (!(secondLevelKey in secondLevelMapping)) { + throw new Error( + `Key ${secondLevelKey} not found in mapping ${mappingLogicalName}.${topLevelKey}. Available keys are ${Object.keys( + secondLevelMapping, + )}`, + ); + } + + const value = secondLevelMapping[secondLevelKey]; + return value; + }, this.processIntrinsics(params)); + } + default: throw new Error(`unsupported intrinsic function ${fn} (params: ${JSON.stringify(params)})`); } diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts index 50afa279..55d3c9b7 100644 --- a/tests/converters/app-converter.test.ts +++ b/tests/converters/app-converter.test.ts @@ -193,64 +193,222 @@ describe('App Converter', () => { }); test.each([ - ['ref', createStackManifest({ Bucket: { Ref: 'resource1' } }), 'resource1_id'], + ['ref', createStackManifest({ resource2Props: { Bucket: { Ref: 'resource1' } } }), 'resource1_id', undefined], [ 'GetAtt', createStackManifest({ - Bucket: { 'Fn::GetAtt': ['resource1', 'Arn'] }, + resource2Props: { + Bucket: { 'Fn::GetAtt': ['resource1', 'Arn'] }, + }, }), 'resource1_arn', + undefined, ], [ 'Join-Ref', createStackManifest({ - Bucket: { 'Fn::Join': ['', ['arn:', { Ref: 'resource1' }]] }, + resource2Props: { + Bucket: { 'Fn::Join': ['', ['arn:', { Ref: 'resource1' }]] }, + }, }), 'arn:resource1_id', + undefined, ], [ 'Split-Select-Ref', createStackManifest({ - Bucket: { 'Fn::Select': ['1', { 'Fn::Split': ['_', { Ref: 'resource1' }] }] }, + resource2Props: { + Bucket: { 'Fn::Select': ['1', { 'Fn::Split': ['_', { Ref: 'resource1' }] }] }, + }, }), 'id', + undefined, ], [ 'Base64-Ref', createStackManifest({ - Bucket: { 'Fn::Base64': { Ref: 'resource1' } }, + resource2Props: { + Bucket: { 'Fn::Base64': { Ref: 'resource1' } }, + }, }), Buffer.from('resource1_id').toString('base64'), + undefined, ], [ 'GetAZs-Select-Ref', createStackManifest({ - Bucket: { 'Fn::Select': ['1', { 'Fn::GetAZs': 'us-east-1' }] }, + resource2Props: { + Bucket: { 'Fn::Select': ['1', { 'Fn::GetAZs': 'us-east-1' }] }, + }, }), 'us-east-1b', + undefined, ], [ 'Sub-Ref', createStackManifest({ - Bucket: { 'Fn::Sub': 'www.${resource1}-${AWS::Region}-${AWS::AccountId}' }, + resource2Props: { + Bucket: { 'Fn::Sub': 'www.${resource1}-${AWS::Region}-${AWS::AccountId}' }, + }, }), 'www.resource1_id-us-east-2-12345678910', + undefined, + ], + [ + 'FindInMap', + createStackManifest({ + resource2Props: { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] }, + }, + mappings: { + Map: { + Key: { + Value: 'result', + }, + }, + }, + }), + 'result', + undefined, + ], + [ + 'FindInMap-Ref', + createStackManifest({ + resource2Props: { + Bucket: { 'Fn::FindInMap': ['Map', { Ref: 'AWS::Region' }, 'Value'] }, + }, + mappings: { + Map: { + ['us-east-2']: { + Value: 'result', + }, + }, + }, + }), + 'result', + undefined, + ], + [ + 'Split-FindInMap-Ref', + createStackManifest({ + resource2Props: { + Bucket: { + 'Fn::Select': [ + '1', + { + 'Fn::Split': [ + ',', + { + 'Fn::FindInMap': ['Map', { Ref: 'AWS::Region' }, 'Value'], + }, + ], + }, + ], + }, + }, + mappings: { + Map: { + ['us-east-2']: { + Value: 'result1,result2', + }, + }, + }, + }), + 'result2', + undefined, + ], + [ + 'FindInMap-id-error', + createStackManifest({ + resource2Props: { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] }, + }, + mappings: { + OtherMap: { + Key: { + Value: 'result', + }, + }, + }, + }), + 'result', + 'Mapping Map not found in mappings. Available mappings are OtherMap', + ], + [ + 'FindInMap-mappings-error', + createStackManifest({ + resource2Props: { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] }, + }, + }), + 'result', + 'No mappings found in stack', + ], + [ + 'FindInMap-mappings-input-error', + createStackManifest({ + resource2Props: { + Bucket: { 'Fn::FindInMap': ['Map', 'Value'] }, + }, + }), + 'result', + 'Fn::FindInMap requires exactly 3 parameters, got 2', + ], + [ + 'FindInMap-topLevel-error', + createStackManifest({ + resource2Props: { + Bucket: { 'Fn::FindInMap': ['Map', 'OtherKey', 'Value'] }, + }, + mappings: { + Map: { + Key: { + Value: 'result', + }, + }, + }, + }), + 'result', + 'Key OtherKey not found in mapping Map. Available keys are Key', + ], + [ + 'FindInMap-secondLevel-error', + createStackManifest({ + resource2Props: { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'OtherValue'] }, + }, + mappings: { + Map: { + Key: { + Value: 'result', + }, + }, + }, + }), + 'result', + 'Key OtherValue not found in mapping Map.Key. Available keys are Value', ], ])( 'intrinsics %s', - async (_name, stackManifest, expected) => { + async (_name, stackManifest, expected, expectedError) => { const mockStackComponent = new MockAppComponent('/tmp/foo/bar/does/not/exist'); const converter = new StackConverter(mockStackComponent, stackManifest); - converter.convert(new Set()); - const promises = Array.from(converter.resources.values()).flatMap((res) => promiseOf(res.resource.urn)); - await Promise.all(promises); - const bucket = converter.resources.get('resource1'); - expect(bucket).toBeDefined(); - const policy = converter.resources.get('resource2'); - expect(policy).toBeDefined(); - const policyResource = policy!.resource as BucketPolicy; - const policyBucket = await promiseOf(policyResource.bucket); - expect(policyBucket).toEqual(expected); + if (expectedError) { + expect(() => { + converter.convert(new Set()); + }).toThrow(expectedError); + } else { + converter.convert(new Set()); + const promises = Array.from(converter.resources.values()).flatMap((res) => promiseOf(res.resource.urn)); + await Promise.all(promises); + const bucket = converter.resources.get('resource1'); + expect(bucket).toBeDefined(); + const policy = converter.resources.get('resource2'); + expect(policy).toBeDefined(); + const policyResource = policy!.resource as BucketPolicy; + const policyBucket = await promiseOf(policyResource.bucket); + expect(policyBucket).toEqual(expected); + } }, 10_000, ); @@ -448,15 +606,15 @@ describe('Stack Converter', () => { const stackManifestPath = path.join(__dirname, '../test-data/custom-resource-stack/stack-manifest.json'); const props: StackManifestProps = JSON.parse(fs.readFileSync(stackManifestPath, 'utf-8')); const manifest = new StackManifest(props); - const app = new MockAppComponent('/tmp/foo/bar/does/not/exist') - const stagingBucket = "my-bucket"; - const customResourcePrefix = "my-prefix"; + const app = new MockAppComponent('/tmp/foo/bar/does/not/exist'); + const stagingBucket = 'my-bucket'; + const customResourcePrefix = 'my-prefix'; app.stacks[manifest.id] = { synthesizer: new MockSynth(stagingBucket, customResourcePrefix), node: { id: 'my-stack', - } - } as unknown as CdkStack + }, + } as unknown as CdkStack; const converter = new StackConverter(app, manifest); converter.convert(new Set()); @@ -464,7 +622,7 @@ describe('Stack Converter', () => { const customResource = converter.resources.get('DeployWebsiteCustomResourceD116527B'); expect(customResource).toBeDefined(); - const customResourceEmulator = customResource?.resource! as native.cloudformation.CustomResourceEmulator; + const customResourceEmulator = customResource!.resource! as native.cloudformation.CustomResourceEmulator; expect(customResourceEmulator.bucket).toBeDefined(); expect(customResourceEmulator.data).toBeDefined(); expect(customResourceEmulator.serviceToken).toBeDefined(); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index da7e2539..9703f6c9 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -201,29 +201,37 @@ describe('GraphBuilder', () => { }); test.each([ - ['dependsOn', createStackManifest({}, {}, ['resource1'])], + ['dependsOn', createStackManifest({ resource2Props: {}, resource2DependsOn: ['resource1'] })], [ 'ref', createStackManifest({ - SomeProp: { Ref: 'resource1' }, + resource2Props: { + SomeProp: { Ref: 'resource1' }, + }, }), ], [ 'GetAtt', createStackManifest({ - SomeProp: { 'Fn::GetAtt': ['resource1', 'Arn'] }, + resource2Props: { + SomeProp: { 'Fn::GetAtt': ['resource1', 'Arn'] }, + }, }), ], [ 'Sub-Ref', createStackManifest({ - SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { Ref: 'resource1' } }] }, + resource2Props: { + SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { Ref: 'resource1' } }] }, + }, }), ], [ 'Sub-GetAtt', createStackManifest({ - SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { 'Fn::GetAtt': ['resource1', 'Arn'] } }] }, + resource2Props: { + SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { 'Fn::GetAtt': ['resource1', 'Arn'] } }] }, + }, }), ], ])('adds edge for %s', (_name, stackManifest) => { @@ -486,11 +494,17 @@ test('parses custom resources', () => { expect(deployWebsiteCR).toBeDefined(); expect(deployWebsiteCR?.construct.type).toEqual('aws-cdk-lib:CfnResource'); expect(deployWebsiteCR?.resource).toBeDefined(); - const deployWebsiteCRResource = deployWebsiteCR?.resource!; + const deployWebsiteCRResource = deployWebsiteCR!.resource!; expect(deployWebsiteCRResource.Type).toEqual('Custom::CDKBucketDeployment'); - expect(deployWebsiteCRResource.Properties.ServiceToken).toEqual({ 'Fn::GetAtt': ['CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536', 'Arn'] }); - expect(deployWebsiteCRResource.Properties.SourceBucketNames).toEqual(['pulumi-cdk-stom-res-d817419f-staging-616138583583-us-west-2']); - expect(deployWebsiteCRResource.Properties.SourceObjectKeys).toEqual(['a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd.zip']); + expect(deployWebsiteCRResource.Properties.ServiceToken).toEqual({ + 'Fn::GetAtt': ['CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536', 'Arn'], + }); + expect(deployWebsiteCRResource.Properties.SourceBucketNames).toEqual([ + 'pulumi-cdk-stom-res-d817419f-staging-616138583583-us-west-2', + ]); + expect(deployWebsiteCRResource.Properties.SourceObjectKeys).toEqual([ + 'a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd.zip', + ]); const testRole = graph.nodes.find((node) => node.logicalId === 'CustomResourceRoleAB1EF463'); expect(testRole).toBeDefined(); @@ -500,7 +514,9 @@ test('parses custom resources', () => { const statement = policies[0].PolicyDocument?.Statement; expect(statement).toBeDefined(); expect(statement).toHaveLength(1); - expect(statement[0].Resource).toEqual({ 'Fn::Join': ['', [{ 'Fn::GetAtt': ['DeployWebsiteCustomResourceD116527B', 'DestinationBucketArn'] }, '/*']] }); + expect(statement[0].Resource).toEqual({ + 'Fn::Join': ['', [{ 'Fn::GetAtt': ['DeployWebsiteCustomResourceD116527B', 'DestinationBucketArn'] }, '/*']], + }); }); function edgesToArray(edges: Set): string[] { diff --git a/tests/utils.ts b/tests/utils.ts index 7168081b..51577e1e 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,11 +1,15 @@ import { StackManifest } from '../src/assembly'; +import { CloudFormationMapping } from '../src/cfn'; -export function createStackManifest( - resource2Props: any, - resource1Props?: any, - resource2DependsOn?: string | string[], - resource1DependsOn?: string | string[], -): StackManifest { +export interface CreateStackManifestProps { + resource1Props?: any; + resource2Props: any; + resource1DependsOn?: string | string[]; + resource2DependsOn?: string | string[]; + mappings?: CloudFormationMapping; +} + +export function createStackManifest(props: CreateStackManifestProps): StackManifest { return new StackManifest({ id: 'stack', templatePath: 'template', @@ -34,19 +38,20 @@ export function createStackManifest( }, }, template: { + Mappings: props.mappings, Resources: { resource1: { Type: 'AWS::S3::Bucket', - Properties: resource1Props ?? {}, - DependsOn: resource1DependsOn, + Properties: props.resource1Props ?? {}, + DependsOn: props.resource1DependsOn, }, resource2: { Type: 'AWS::S3::BucketPolicy', Properties: { policyDocument: {}, - ...resource2Props, + ...props.resource2Props, }, - DependsOn: resource2DependsOn, + DependsOn: props.resource2DependsOn, }, }, },