From 88e1d0fd539bcfb1af6aa80bd1e8daa01bd7b669 Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Fri, 15 Nov 2024 10:30:35 -0500 Subject: [PATCH] Add support for Fn::FindInMap intrinsic This adds support for the CloudFormation `Fn::FindInMap` intrinsic function. Docs on the `Mappings` section of a CloudFormation template can be found [here](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html). Docs on the `Fn::FindInMap` function can be found [here](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-findinmap.html) closes #187 --- integration/apigateway/index.ts | 3 +- integration/apigateway/sfn-api.ts | 7 +- integration/misc-services/index.ts | 8 + src/assembly/stack.ts | 9 +- src/cfn.ts | 6 + src/converters/app-converter.ts | 41 +++++- tests/converters/app-converter.test.ts | 196 +++++++++++++++++++++++-- tests/utils.ts | 3 + 8 files changed, 255 insertions(+), 18 deletions(-) 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/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 0d4824b3..3ee2bf29 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -17,11 +17,11 @@ 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'; -import { processSecretsManagerReferenceValue, resolveSecretsManagerDynamicReference } from './secrets-manager-dynamic'; +import { processSecretsManagerReferenceValue } from './secrets-manager-dynamic'; /** * AppConverter will convert all CDK resources into Pulumi resources. @@ -462,6 +462,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 60faf817..428d6502 100644 --- a/tests/converters/app-converter.test.ts +++ b/tests/converters/app-converter.test.ts @@ -191,13 +191,14 @@ describe('App Converter', () => { }); test.each([ - ['ref', createStackManifest({ Bucket: { Ref: 'resource1' } }), 'resource1_id'], + ['ref', createStackManifest({ Bucket: { Ref: 'resource1' } }), 'resource1_id', undefined], [ 'GetAtt', createStackManifest({ Bucket: { 'Fn::GetAtt': ['resource1', 'Arn'] }, }), 'resource1_arn', + undefined, ], [ 'Join-Ref', @@ -205,6 +206,7 @@ describe('App Converter', () => { Bucket: { 'Fn::Join': ['', ['arn:', { Ref: 'resource1' }]] }, }), 'arn:resource1_id', + undefined, ], [ 'Split-Select-Ref', @@ -212,6 +214,7 @@ describe('App Converter', () => { Bucket: { 'Fn::Select': ['1', { 'Fn::Split': ['_', { Ref: 'resource1' }] }] }, }), 'id', + undefined, ], [ 'Base64-Ref', @@ -219,6 +222,7 @@ describe('App Converter', () => { Bucket: { 'Fn::Base64': { Ref: 'resource1' } }, }), Buffer.from('resource1_id').toString('base64'), + undefined, ], [ 'GetAZs-Select-Ref', @@ -226,6 +230,7 @@ describe('App Converter', () => { Bucket: { 'Fn::Select': ['1', { 'Fn::GetAZs': 'us-east-1' }] }, }), 'us-east-1b', + undefined, ], [ 'Sub-Ref', @@ -233,22 +238,189 @@ describe('App Converter', () => { Bucket: { 'Fn::Sub': 'www.${resource1}-${AWS::Region}-${AWS::AccountId}' }, }), 'www.resource1_id-us-east-2-12345678910', + undefined, + ], + [ + 'FindInMap', + createStackManifest( + { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] }, + }, + undefined, + undefined, + undefined, + { + Map: { + Key: { + Value: 'result', + }, + }, + }, + ), + 'result', + undefined, + ], + [ + 'FindInMap-Ref', + createStackManifest( + { + Bucket: { 'Fn::FindInMap': ['Map', { Ref: 'AWS::Region' }, 'Value'] }, + }, + undefined, + undefined, + undefined, + { + Map: { + ['us-east-2']: { + Value: 'result', + }, + }, + }, + ), + 'result', + undefined, + ], + [ + 'Split-FindInMap-Ref', + createStackManifest( + { + Bucket: { + 'Fn::Select': [ + '1', + { + 'Fn::Split': [ + ',', + { + 'Fn::FindInMap': ['Map', { Ref: 'AWS::Region' }, 'Value'], + }, + ], + }, + ], + }, + }, + undefined, + undefined, + undefined, + { + Map: { + ['us-east-2']: { + Value: 'result1,result2', + }, + }, + }, + ), + 'result2', + undefined, + ], + [ + 'FindInMap-id-error', + createStackManifest( + { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] }, + }, + undefined, + undefined, + undefined, + { + OtherMap: { + Key: { + Value: 'result', + }, + }, + }, + ), + 'result', + 'Mapping Map not found in mappings. Available mappings are OtherMap', + ], + [ + 'FindInMap-mappings-error', + createStackManifest( + { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] }, + }, + undefined, + undefined, + undefined, + undefined, + ), + 'result', + 'No mappings found in stack', + ], + [ + 'FindInMap-mappings-input-error', + createStackManifest( + { + Bucket: { 'Fn::FindInMap': ['Map', 'Value'] }, + }, + undefined, + undefined, + undefined, + undefined, + ), + 'result', + 'Fn::FindInMap requires exactly 3 parameters, got 2', + ], + [ + 'FindInMap-topLevel-error', + createStackManifest( + { + Bucket: { 'Fn::FindInMap': ['Map', 'OtherKey', 'Value'] }, + }, + undefined, + undefined, + undefined, + { + Map: { + Key: { + Value: 'result', + }, + }, + }, + ), + 'result', + 'Key OtherKey not found in mapping Map. Available keys are Key', + ], + [ + 'FindInMap-secondLevel-error', + createStackManifest( + { + Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'OtherValue'] }, + }, + undefined, + undefined, + undefined, + { + 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, ); diff --git a/tests/utils.ts b/tests/utils.ts index 7168081b..8b38d885 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,10 +1,12 @@ import { StackManifest } from '../src/assembly'; +import { CloudFormationMapping } from '../src/cfn'; export function createStackManifest( resource2Props: any, resource1Props?: any, resource2DependsOn?: string | string[], resource1DependsOn?: string | string[], + mappings?: CloudFormationMapping, ): StackManifest { return new StackManifest({ id: 'stack', @@ -34,6 +36,7 @@ export function createStackManifest( }, }, template: { + Mappings: mappings, Resources: { resource1: { Type: 'AWS::S3::Bucket',