From a0383047d2d57941c41559a66fdbcba6e424269b Mon Sep 17 00:00:00 2001 From: Adam Ruka Date: Wed, 19 Aug 2020 02:56:35 -0700 Subject: [PATCH] feat(cfn-include): add support for retrieving Mapping objects from the template (#9777) Fixes #9711 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/cloudformation-include/README.md | 18 +++++ .../cloudformation-include/lib/cfn-include.ts | 65 ++++++++++++++-- .../test/invalid-templates.test.ts | 8 +- .../invalid/non-existent-mapping.json | 16 ++++ .../only-mapping-and-bucket.json | 23 ++++++ .../test/valid-templates.test.ts | 75 ++++++++++++++++++- packages/@aws-cdk/core/lib/cfn-parse.ts | 15 +++- 7 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/non-existent-mapping.json create mode 100644 packages/@aws-cdk/cloudformation-include/test/test-templates/only-mapping-and-bucket.json diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md index 180f7b7ea0ecc..01bcf764ea54f 100644 --- a/packages/@aws-cdk/cloudformation-include/README.md +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -163,6 +163,24 @@ and any changes you make to it will be reflected in the resulting template: condition.expression = core.Fn.conditionEquals(1, 2); ``` +## Mappings + +If your template uses [CloudFormation Mappings](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html), +you can retrieve them from your template: + +```typescript +import * as core from '@aws-cdk/core'; + +const mapping: core.CfnMapping = cfnTemplate.getMapping('MyMapping'); +``` + +The `CfnMapping` object is mutable, +and any changes you make to it will be reflected in the resulting template: + +```typescript +mapping.setValue('my-region', 'AMI', 'ami-04681a1dbd79675a5'); +``` + ## Outputs If your template uses [CloudFormation Outputs](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html), diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts index 75577a654af51..a241c3bd59d75 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -68,6 +68,8 @@ export class CfnInclude extends core.CfnElement { private readonly resources: { [logicalId: string]: core.CfnResource } = {}; private readonly parameters: { [logicalId: string]: core.CfnParameter } = {}; private readonly parametersToReplace: { [parameterName: string]: any }; + private readonly mappingsScope: core.Construct; + private readonly mappings: { [mappingName: string]: core.CfnMapping } = {}; private readonly outputs: { [logicalId: string]: core.CfnOutput } = {}; private readonly nestedStacks: { [logicalId: string]: IncludedNestedStack } = {}; private readonly nestedStacksToInclude: { [name: string]: CfnIncludeProps }; @@ -92,6 +94,12 @@ export class CfnInclude extends core.CfnElement { } } + // instantiate the Mappings + this.mappingsScope = new core.Construct(this, '$Mappings'); + for (const mappingName of Object.keys(this.template.Mappings || {})) { + this.createMapping(mappingName); + } + // instantiate all parameters for (const logicalId of Object.keys(this.template.Parameters || {})) { this.createParameter(logicalId); @@ -104,12 +112,10 @@ export class CfnInclude extends core.CfnElement { } this.nestedStacksToInclude = props.nestedStacks || {}; - // instantiate all resources as CDK L1 objects for (const logicalId of Object.keys(this.template.Resources || {})) { this.getOrCreateResource(logicalId); } - // verify that all nestedStacks have been instantiated for (const nestedStackId of Object.keys(props.nestedStacks || {})) { if (!(nestedStackId in this.resources)) { @@ -118,7 +124,6 @@ export class CfnInclude extends core.CfnElement { } const outputScope = new core.Construct(this, '$Ouputs'); - for (const logicalId of Object.keys(this.template.Outputs || {})) { this.createOutput(logicalId, outputScope); } @@ -168,7 +173,7 @@ export class CfnInclude extends core.CfnElement { /** * Returns the CfnParameter object from the 'Parameters' - * section of the included template + * section of the included template. * Any modifications performed on that object will be reflected in the resulting CDK template. * * If a Parameter with the given name is not present in the template, @@ -184,9 +189,26 @@ export class CfnInclude extends core.CfnElement { return ret; } + /** + * Returns the CfnMapping object from the 'Mappings' section of the included template. + * Any modifications performed on that object will be reflected in the resulting CDK template. + * + * If a Mapping with the given name is not present in the template, + * an exception will be thrown. + * + * @param mappingName the name of the Mapping in the template to retrieve + */ + public getMapping(mappingName: string): core.CfnMapping { + const ret = this.mappings[mappingName]; + if (!ret) { + throw new Error(`Mapping with name '${mappingName}' was not found in the template`); + } + return ret; + } + /** * Returns the CfnOutput object from the 'Outputs' - * section of the included template + * section of the included template. * Any modifications performed on that object will be reflected in the resulting CDK template. * * If an Output with the given name is not present in the template, @@ -205,7 +227,9 @@ export class CfnInclude extends core.CfnElement { /** * Returns the NestedStack with name logicalId. * For a nested stack to be returned by this method, it must be specified in the {@link CfnIncludeProps.nestedStacks} - * @param logicalId the ID of the stack to retrieve, as it appears in the template. + * property. + * + * @param logicalId the ID of the stack to retrieve, as it appears in the template */ public getNestedStack(logicalId: string): IncludedNestedStack { if (!this.nestedStacks[logicalId]) { @@ -236,6 +260,9 @@ export class CfnInclude extends core.CfnElement { findCondition(conditionName: string): core.CfnCondition | undefined { return self.conditions[conditionName]; }, + findMapping(mappingName): core.CfnMapping | undefined { + return self.mappings[mappingName]; + }, }; const cfnParser = new cfn_parse.CfnParser({ finder, @@ -244,6 +271,7 @@ export class CfnInclude extends core.CfnElement { switch (section) { case 'Conditions': + case 'Mappings': case 'Resources': case 'Parameters': case 'Outputs': @@ -257,6 +285,22 @@ export class CfnInclude extends core.CfnElement { return ret; } + private createMapping(mappingName: string): void { + const cfnParser = new cfn_parse.CfnParser({ + finder: { + findCondition() { throw new Error('Referring to Conditions in Mapping definitions is not allowed'); }, + findMapping() { throw new Error('Referring to other Mappings in Mapping definitions is not allowed'); }, + findRefTarget() { throw new Error('Using Ref expressions in Mapping definitions is not allowed'); }, + findResource() { throw new Error('Using GetAtt expressions in Mapping definitions is not allowed'); }, + }, + }); + const cfnMapping = new core.CfnMapping(this.mappingsScope, mappingName, { + mapping: cfnParser.parseValue(this.template.Mappings[mappingName]), + }); + this.mappings[mappingName] = cfnMapping; + cfnMapping.overrideLogicalId(mappingName); + } + private createParameter(logicalId: string): void { if (logicalId in this.parametersToReplace) { return; @@ -267,6 +311,7 @@ export class CfnInclude extends core.CfnElement { findResource() { throw new Error('Using GetAtt expressions in Parameter definitions is not allowed'); }, findRefTarget() { throw new Error('Using Ref expressions in Parameter definitions is not allowed'); }, findCondition() { throw new Error('Referring to Conditions in Parameter definitions is not allowed'); }, + findMapping() { throw new Error('Referring to Mappings in Parameter definitions is not allowed'); }, }, }).parseValue(this.template.Parameters[logicalId]); const cfnParameter = new core.CfnParameter(this, logicalId, { @@ -300,6 +345,9 @@ export class CfnInclude extends core.CfnElement { findCondition(): undefined { return undefined; }, + findMapping(mappingName): core.CfnMapping | undefined { + return self.mappings[mappingName]; + }, }, parameters: this.parametersToReplace, }).parseValue(this.template.Outputs[logicalId]); @@ -341,6 +389,7 @@ export class CfnInclude extends core.CfnElement { ? self.getOrCreateCondition(cName) : undefined; }, + findMapping() { throw new Error('Using FindInMap in Condition definitions is not allowed'); }, }, context: cfn_parse.CfnParsingContext.CONDITIONS, parameters: this.parametersToReplace, @@ -381,6 +430,10 @@ export class CfnInclude extends core.CfnElement { return self.conditions[conditionName]; }, + findMapping(mappingName): core.CfnMapping | undefined { + return self.mappings[mappingName]; + }, + findResource(lId: string): core.CfnResource | undefined { if (!(lId in (self.template.Resources || {}))) { return undefined; diff --git a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts index 0a2a636a546c3..f655be2f64a8e 100644 --- a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts @@ -95,12 +95,18 @@ describe('CDK Include', () => { }).toThrow(/Resource used in GetAtt expression with logical ID: 'DoesNotExist' not found/); }); - test("throws a validation exception when an output references a condition that doesn't exist", () => { + test("throws a validation exception when an Output references a Condition that doesn't exist", () => { expect(() => { includeTestTemplate(stack, 'output-referencing-nonexistant-condition.json'); }).toThrow(/Output with name 'SomeOutput' refers to a Condition with name 'NonexistantCondition' which was not found in this template/); }); + test("throws a validation exception when a Resource property references a Mapping that doesn't exist", () => { + expect(() => { + includeTestTemplate(stack, 'non-existent-mapping.json'); + }).toThrow(/Mapping used in FindInMap expression with name 'NonExistentMapping' was not found in the template/); + }); + test("throws a validation exception when Fn::Sub in string form uses a key that isn't in the template", () => { expect(() => { includeTestTemplate(stack, 'fn-sub-key-not-in-template-string.json'); diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/non-existent-mapping.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/non-existent-mapping.json new file mode 100644 index 0000000000000..1af26779590f4 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/non-existent-mapping.json @@ -0,0 +1,16 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::FindInMap": [ + "NonExistentMapping", + { "Ref": "AWS::Region" }, + "key1" + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/only-mapping-and-bucket.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-mapping-and-bucket.json new file mode 100644 index 0000000000000..a71a2c62bcf55 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-mapping-and-bucket.json @@ -0,0 +1,23 @@ +{ + "Mappings": { + "SomeMapping": { + "region": { + "key1": "value1" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::FindInMap": [ + "SomeMapping", + { "Ref": "AWS::Region" }, + "key1" + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts index 6b7d3835c9287..29527d61aa96a 100644 --- a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -665,7 +665,78 @@ describe('CDK Include', () => { }).toThrow(/Output with logical ID 'FakeOutput' was not found in the template/); }); - test('replaces references to parameters with the user-specified values in Resources, Conditions, Metadata, and Options', () => { + test('can ingest a template that contains Mappings, and retrieve those Mappings', () => { + const cfnTemplate = includeTestTemplate(stack, 'only-mapping-and-bucket.json'); + const someMapping = cfnTemplate.getMapping('SomeMapping'); + + someMapping.setValue('region', 'key2', 'value2'); + + expect(stack).toMatchTemplate({ + "Mappings": { + "SomeMapping": { + "region": { + "key1": "value1", + "key2": "value2", + }, + }, + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::FindInMap": [ + "SomeMapping", + { "Ref": "AWS::Region" }, + "key1", + ], + }, + }, + }, + }, + }); + }); + + test("throws an exception when attempting to retrieve a Mapping that doesn't exist in the template", () => { + const cfnTemplate = includeTestTemplate(stack, 'only-mapping-and-bucket.json'); + + expect(() => { + cfnTemplate.getMapping('NonExistentMapping'); + }).toThrow(/Mapping with name 'NonExistentMapping' was not found in the template/); + }); + + test('handles renaming Mapping references', () => { + const cfnTemplate = includeTestTemplate(stack, 'only-mapping-and-bucket.json'); + const someMapping = cfnTemplate.getMapping('SomeMapping'); + + someMapping.overrideLogicalId('DifferentMapping'); + + expect(stack).toMatchTemplate({ + "Mappings": { + "DifferentMapping": { + "region": { + "key1": "value1", + }, + }, + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::FindInMap": [ + "DifferentMapping", + { "Ref": "AWS::Region" }, + "key1", + ], + }, + }, + }, + }, + }); + }); + + test('replaces references to parameters with the user-specified values in Resources, Conditions, Metadata, and Options sections', () => { includeTestTemplate(stack, 'parameter-references.json', { parameters: { 'MyParam': 'my-s3-bucket', @@ -712,7 +783,7 @@ describe('CDK Include', () => { }); }); - test('can replace parameters in Fn::Sub', () => { + test('replaces parameters in Fn::Sub expressions', () => { includeTestTemplate(stack, 'fn-sub-parameters.json', { parameters: { 'MyParam': 'my-s3-bucket', diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts index bab5b50d8b49d..4e3343e9aa248 100644 --- a/packages/@aws-cdk/core/lib/cfn-parse.ts +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -1,6 +1,7 @@ import { CfnCondition } from './cfn-condition'; import { CfnElement } from './cfn-element'; import { Fn } from './cfn-fn'; +import { CfnMapping } from './cfn-mapping'; import { Aws } from './cfn-pseudo'; import { CfnResource } from './cfn-resource'; import { @@ -168,6 +169,13 @@ export interface ICfnFinder { */ findCondition(conditionName: string): CfnCondition | undefined; + /** + * Return the Mapping with the given name from the template. + * If there is no Mapping with that name in the template, + * returns undefined. + */ + findMapping(mappingName: string): CfnMapping | undefined; + /** * Returns the element referenced using a Ref expression with the given name. * If there is no element with this name in the template, @@ -452,7 +460,12 @@ export class CfnParser { } case 'Fn::FindInMap': { const value = this.parseValue(object[key]); - return Fn.findInMap(value[0], value[1], value[2]); + // the first argument to FindInMap is the mapping name + const mapping = this.options.finder.findMapping(value[0]); + if (!mapping) { + throw new Error(`Mapping used in FindInMap expression with name '${value[0]}' was not found in the template`); + } + return Fn.findInMap(mapping.logicalId, value[1], value[2]); } case 'Fn::Select': { const value = this.parseValue(object[key]);