diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md index 0e51dc0402f16..180f7b7ea0ecc 100644 --- a/packages/@aws-cdk/cloudformation-include/README.md +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -131,6 +131,20 @@ and any changes you make to it will be reflected in the resulting template: param.default = 'MyDefault'; ``` +You can also provide values for them when including the template: + +```typescript +new inc.CfnInclude(stack, 'includeTemplate', { + templateFile: 'path/to/my/template' + parameters: { + 'MyParam': 'my-value', + }, +}); +``` + +This will replace all references to `MyParam` with the string 'my-value', +and `MyParam` will be removed from the Parameters section of the template. + ## Conditions If your template uses [CloudFormation Conditions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-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 157a0ebaf6ccf..584a5f97b420b 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -23,8 +23,20 @@ export interface CfnIncludeProps { * If you include a stack here with an ID that isn't in the template, * or is in the template but is not a nested stack, * template creation will fail and an error will be thrown. + * + * @default - no nested stacks will be included */ readonly nestedStacks?: { [stackName: string]: CfnIncludeProps }; + + /** + * Specifies parameters to be replaced by the values in this mapping. + * Any parameters in the template that aren't specified here will be left unmodified. + * If you include a parameter here with an ID that isn't in the template, + * template creation will fail and an error will be thrown. + * + * @default - no parameters will be replaced + */ + readonly parameters?: { [parameterName: string]: any }; } /** @@ -55,6 +67,7 @@ export class CfnInclude extends core.CfnElement { private readonly conditionsScope: core.Construct; private readonly resources: { [logicalId: string]: core.CfnResource } = {}; private readonly parameters: { [logicalId: string]: core.CfnParameter } = {}; + private readonly parametersToReplace: { [parameterName: string]: any }; private readonly outputs: { [logicalId: string]: core.CfnOutput } = {}; private readonly nestedStacks: { [logicalId: string]: IncludedNestedStack } = {}; private readonly nestedStacksToInclude: { [name: string]: CfnIncludeProps }; @@ -64,12 +77,21 @@ export class CfnInclude extends core.CfnElement { constructor(scope: core.Construct, id: string, props: CfnIncludeProps) { super(scope, id); + this.parametersToReplace = props.parameters || {}; + // read the template into a JS object this.template = futils.readYamlSync(props.templateFile); // ToDo implement preserveLogicalIds=false this.preserveLogicalIds = true; + // check if all user specified parameter values exist in the template + for (const logicalId of Object.keys(this.parametersToReplace)) { + if (!(logicalId in (this.template.Parameters || {}))) { + throw new Error(`Parameter with logical ID '${logicalId}' was not found in the template`); + } + } + // instantiate all parameters for (const logicalId of Object.keys(this.template.Parameters || {})) { this.createParameter(logicalId); @@ -203,10 +225,32 @@ export class CfnInclude extends core.CfnElement { const ret: { [section: string]: any } = {}; for (const section of Object.keys(this.template)) { - // render all sections of the template unchanged, - // except Conditions, Resources, Parameters, and Outputs which will be taken care of by the created L1s - if (section !== 'Conditions' && section !== 'Resources' && section !== 'Parameters' && section !== 'Outputs') { - ret[section] = this.template[section]; + const self = this; + const finder: cfn_parse.ICfnFinder = { + findResource(lId): core.CfnResource | undefined { + return self.resources[lId]; + }, + findRefTarget(elementName: string): core.CfnElement | undefined { + return self.resources[elementName] ?? self.parameters[elementName]; + }, + findCondition(conditionName: string): core.CfnCondition | undefined { + return self.conditions[conditionName]; + }, + }; + const cfnParser = new cfn_parse.CfnParser({ + finder, + parameters: this.parametersToReplace, + }); + + switch (section) { + case 'Conditions': + case 'Resources': + case 'Parameters': + case 'Outputs': + // these are rendered as a side effect of instantiating the L1s + break; + default: + ret[section] = cfnParser.parseValue(this.template[section]); } } @@ -214,6 +258,10 @@ export class CfnInclude extends core.CfnElement { } private createParameter(logicalId: string): void { + if (logicalId in this.parametersToReplace) { + return; + } + const expression = new cfn_parse.CfnParser({ finder: { findResource() { throw new Error('Using GetAtt expressions in Parameter definitions is not allowed'); }, @@ -253,6 +301,7 @@ export class CfnInclude extends core.CfnElement { return undefined; }, }, + parameters: this.parametersToReplace, }).parseValue(this.template.Outputs[logicalId]); const cfnOutput = new core.CfnOutput(scope, logicalId, { value: outputAttributes.Value, @@ -294,6 +343,7 @@ export class CfnInclude extends core.CfnElement { }, }, context: cfn_parse.CfnParsingContext.CONDITIONS, + parameters: this.parametersToReplace, }); const cfnCondition = new core.CfnCondition(this.conditionsScope, conditionName, { expression: cfnParser.parseValue(this.template.Conditions[conditionName]), @@ -326,7 +376,7 @@ export class CfnInclude extends core.CfnElement { } const self = this; - const finder: core.ICfnFinder = { + const finder: cfn_parse.ICfnFinder = { findCondition(conditionName: string): core.CfnCondition | undefined { return self.conditions[conditionName]; }, @@ -348,6 +398,7 @@ export class CfnInclude extends core.CfnElement { }; const cfnParser = new cfn_parse.CfnParser({ finder, + parameters: this.parametersToReplace, }); let l1Instance: core.CfnResource; @@ -356,13 +407,13 @@ export class CfnInclude extends core.CfnElement { } else { const l1ClassFqn = cfn_type_to_l1_mapping.lookup(resourceAttributes.Type); if (l1ClassFqn) { - const options: core.FromCloudFormationOptions = { - finder, + const options: cfn_parse.FromCloudFormationOptions = { + parser: cfnParser, }; const [moduleName, ...className] = l1ClassFqn.split('.'); const module = require(moduleName); // eslint-disable-line @typescript-eslint/no-require-imports const jsClassFromModule = module[className.join('.')]; - l1Instance = jsClassFromModule.fromCloudFormation(this, logicalId, resourceAttributes, options); + l1Instance = jsClassFromModule._fromCloudFormation(this, logicalId, resourceAttributes, options); } else { l1Instance = new core.CfnResource(this, logicalId, { type: resourceAttributes.Type, diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/bucket-with-parameters.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/bucket-with-parameters.json index 0cf4552cb2951..5268e6495b074 100644 --- a/packages/@aws-cdk/cloudformation-include/test/test-templates/bucket-with-parameters.json +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/bucket-with-parameters.json @@ -45,4 +45,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-parameters.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-parameters.json new file mode 100644 index 0000000000000..d1bfe825b2514 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-parameters.json @@ -0,0 +1,19 @@ +{ + "Parameters": { + "MyParam": { + "Type": "String" + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": [ + "${MyParam}" + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow-parameter.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow-parameter.json new file mode 100644 index 0000000000000..1e47c582ba875 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow-parameter.json @@ -0,0 +1,22 @@ +{ + "Parameters": { + "MyParam": { + "Type": "String" + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": [ + "${MyParam}", + { + "MyParam": { "Ref" : "MyParam" } + } + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/parameter-references.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/parameter-references.json new file mode 100644 index 0000000000000..2da6e28215241 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/parameter-references.json @@ -0,0 +1,54 @@ +{ + "Transform" : { + "Name" : "AWS::Include", + "Parameters" : { + "Location" : { + "Ref": "MyParam" + } + } + }, + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "MyValue" + } + }, + "Conditions": { + "AlwaysFalse": { + "Fn::Equals": [ { "Ref": "MyParam" }, "Invalid?BucketName"] + } + }, + "Metadata": { + "Field": { + "Fn::If": [ + "AlwaysFalse", + "AWS::NoValue", + { + "Ref": "MyParam" + } + ] + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Metadata": { + "Field": { + "Ref": "MyParam" + } + }, + "Properties": { + "BucketName": { + "Ref": "MyParam" + } + } + } + }, + "Outputs": { + "MyOutput": { + "Value": { + "Ref": "MyParam" + } + } + } +} \ No newline at end of file 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 845aa001ba3d2..fec9ebf7f368c 100644 --- a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -657,16 +657,144 @@ describe('CDK Include', () => { cfnTemplate.getOutput('FakeOutput'); }).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', () => { + includeTestTemplate(stack, 'parameter-references.json', { + parameters: { + 'MyParam': 'my-s3-bucket', + }, + }); + + expect(stack).toMatchTemplate({ + "Transform": { + "Name": "AWS::Include", + "Parameters": { + "Location": "my-s3-bucket", + }, + }, + "Metadata": { + "Field": { + "Fn::If": [ + "AlwaysFalse", + "AWS::NoValue", + "my-s3-bucket", + ], + }, + }, + "Conditions": { + "AlwaysFalse": { + "Fn::Equals": [ "my-s3-bucket", "Invalid?BucketName"], + }, + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Metadata": { + "Field": "my-s3-bucket", + }, + "Properties": { + "BucketName": "my-s3-bucket", + }, + }, + }, + "Outputs": { + "MyOutput": { + "Value": "my-s3-bucket", + }, + }, + }); + }); + + test('can replace parameters in Fn::Sub', () => { + includeTestTemplate(stack, 'fn-sub-parameters.json', { + parameters: { + 'MyParam': 'my-s3-bucket', + }, + }); + + expect(stack).toMatchTemplate({ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "my-s3-bucket", + }, + }, + }, + }, + }); + }); + + test('does not modify Fn::Sub variables shadowing a replaced parameter', () => { + includeTestTemplate(stack, 'fn-sub-shadow-parameter.json', { + parameters: { + 'MyParam': 'MyValue', + }, + }); + + expect(stack).toMatchTemplate({ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": [ + "${MyParam}", + { + "MyParam": "MyValue", + }, + ], + }, + }, + }, + }, + }); + }); + + test('throws an exception when parameters are passed a resource name', () => { + expect(() => { + includeTestTemplate(stack, 'bucket-with-parameters.json', { + parameters: { + 'Bucket': 'noChange', + }, + }); + }).toThrow(/Parameter with logical ID 'Bucket' was not found in the template/); + }); + + test('throws an exception when provided a parameter to replace that is not in the template with parameters', () => { + expect(() => { + includeTestTemplate(stack, 'bucket-with-parameters.json', { + parameters: { + 'FakeParameter': 'DoesNotExist', + }, + }); + }).toThrow(/Parameter with logical ID 'FakeParameter' was not found in the template/); + }); + + test('throws an exception when provided a parameter to replace in a template with no parameters', () => { + expect(() => { + includeTestTemplate(stack, 'only-empty-bucket.json', { + parameters: { + 'FakeParameter': 'DoesNotExist', + }, + }); + }).toThrow(/Parameter with logical ID 'FakeParameter' was not found in the template/); + }); }); interface IncludeTestTemplateProps { /** @default true */ readonly preserveLogicalIds?: boolean; + + /** @default {} */ + readonly parameters?: { [parameterName: string]: any } } -function includeTestTemplate(scope: core.Construct, testTemplate: string, _props: IncludeTestTemplateProps = {}): inc.CfnInclude { +function includeTestTemplate(scope: core.Construct, testTemplate: string, props: IncludeTestTemplateProps = {}): inc.CfnInclude { return new inc.CfnInclude(scope, 'MyScope', { templateFile: _testTemplateFilePath(testTemplate), + parameters: props.parameters, // preserveLogicalIds: props.preserveLogicalIds, }); } diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts index 5872399e0efee..13662e4633244 100644 --- a/packages/@aws-cdk/core/lib/cfn-parse.ts +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -1,3 +1,5 @@ +import { CfnCondition } from './cfn-condition'; +import { CfnElement } from './cfn-element'; import { Fn } from './cfn-fn'; import { Aws } from './cfn-pseudo'; import { CfnResource } from './cfn-resource'; @@ -6,7 +8,6 @@ import { CfnCreationPolicy, CfnDeletionPolicy, CfnResourceAutoScalingCreationPolicy, CfnResourceSignal, CfnUpdatePolicy, } from './cfn-resource-policy'; import { CfnTag } from './cfn-tag'; -import { ICfnFinder } from './from-cfn'; import { Lazy } from './lazy'; import { CfnReference } from './private/cfn-reference'; import { IResolvable } from './resolvable'; @@ -142,6 +143,44 @@ export class FromCloudFormation { } } +/** + * An interface that represents callbacks into a CloudFormation template. + * Used by the fromCloudFormation methods in the generated L1 classes. + */ +export interface ICfnFinder { + /** + * Return the Condition with the given name from the template. + * If there is no Condition with that name in the template, + * returns undefined. + */ + findCondition(conditionName: string): CfnCondition | undefined; + + /** + * Returns the element referenced using a Ref expression with the given name. + * If there is no element with this name in the template, + * return undefined. + */ + findRefTarget(elementName: string): CfnElement | undefined; + + /** + * Returns the resource with the given logical ID in the template. + * If a resource with that logical ID was not found in the template, + * returns undefined. + */ + findResource(logicalId: string): CfnResource | undefined; +} + +/** + * The interface used as the last argument to the fromCloudFormation + * static method of the generated L1 classes. + */ +export interface FromCloudFormationOptions { + /** + * The parser used to convert CloudFormation to values the CDK understands. + */ + readonly parser: CfnParser; +} + /** * The context in which the parsing is taking place. * @@ -171,6 +210,12 @@ export interface ParseCfnOptions { * @default - the default context (no special behavior) */ readonly context?: CfnParsingContext; + + /** + * Values provided here will replace references to parameters in the parsed template. + * @default - no parameters will be replaced + */ + readonly parameters?: { [parameterName: string]: any } } /** @@ -357,7 +402,7 @@ export class CfnParser { return undefined; case 'Ref': { const refTarget = object[key]; - const specialRef = specialCaseRefs(refTarget); + const specialRef = this.specialCaseRefs(refTarget); if (specialRef) { return specialRef; } else { @@ -509,7 +554,7 @@ export class CfnParser { } // since it's not in the map, check if it's a pseudo parameter - const specialRef = specialCaseSubRefs(refTarget); + const specialRef = this.specialCaseSubRefs(refTarget); if (specialRef) { return leftHalf + specialRef + this.parseFnSubString(rightHalf, map); } @@ -532,24 +577,34 @@ export class CfnParser { return leftHalf + CfnReference.for(refResource, attribute, true).toString() + this.parseFnSubString(rightHalf, map); } } -} -function specialCaseRefs(value: any): any { - switch (value) { - case 'AWS::AccountId': return Aws.ACCOUNT_ID; - case 'AWS::Region': return Aws.REGION; - case 'AWS::Partition': return Aws.PARTITION; - case 'AWS::URLSuffix': return Aws.URL_SUFFIX; - case 'AWS::NotificationARNs': return Aws.NOTIFICATION_ARNS; - case 'AWS::StackId': return Aws.STACK_ID; - case 'AWS::StackName': return Aws.STACK_NAME; - case 'AWS::NoValue': return Aws.NO_VALUE; - default: return undefined; + private specialCaseRefs(value: any): any { + if (value in this.parameters) { + return this.parameters[value]; + } + switch (value) { + case 'AWS::AccountId': return Aws.ACCOUNT_ID; + case 'AWS::Region': return Aws.REGION; + case 'AWS::Partition': return Aws.PARTITION; + case 'AWS::URLSuffix': return Aws.URL_SUFFIX; + case 'AWS::NotificationARNs': return Aws.NOTIFICATION_ARNS; + case 'AWS::StackId': return Aws.STACK_ID; + case 'AWS::StackName': return Aws.STACK_NAME; + case 'AWS::NoValue': return Aws.NO_VALUE; + default: return undefined; + } } -} -function specialCaseSubRefs(value: string): string | undefined { - return value.indexOf('::') === -1 ? undefined: '${' + value + '}'; + private specialCaseSubRefs(value: string): string | undefined { + if (value in this.parameters) { + return this.parameters[value]; + } + return value.indexOf('::') === -1 ? undefined: '${' + value + '}'; + } + + private get parameters(): { [parameterName: string]: any } { + return this.options.parameters || {}; + } } function undefinedIfAllValuesAreEmpty(object: object): object | undefined { diff --git a/packages/@aws-cdk/core/lib/from-cfn.ts b/packages/@aws-cdk/core/lib/from-cfn.ts deleted file mode 100644 index 771af7775a74b..0000000000000 --- a/packages/@aws-cdk/core/lib/from-cfn.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CfnCondition } from './cfn-condition'; -import { CfnElement } from './cfn-element'; -import { CfnResource } from './cfn-resource'; - -/** - * An interface that represents callbacks into a CloudFormation template. - * Used by the fromCloudFormation methods in the generated L1 classes. - * - * @experimental - */ -export interface ICfnFinder { - /** - * Return the Condition with the given name from the template. - * If there is no Condition with that name in the template, - * returns undefined. - */ - findCondition(conditionName: string): CfnCondition | undefined; - - /** - * Returns the element referenced using a Ref expression with the given name. - * If there is no element with this name in the template, - * return undefined. - */ - findRefTarget(elementName: string): CfnElement | undefined; - - /** - * Returns the resource with the given logical ID in the template. - * If a resource with that logical ID was not found in the template, - * returns undefined. - */ - findResource(logicalId: string): CfnResource | undefined; -} - -/** - * The interface used as the last argument to the fromCloudFormation - * static method of the generated L1 classes. - * - * @experimental - */ -export interface FromCloudFormationOptions { - /** - * The finder interface used to resolve references across the template. - */ - readonly finder: ICfnFinder; -} diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 6c54a222901d6..92a6ae2f825ed 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -30,7 +30,6 @@ export * from './cfn-json'; export * from './removal-policy'; export * from './arn'; export * from './duration'; -export * from './from-cfn'; export * from './size'; export * from './stack-trace'; diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 6624053395dc2..264f4c87c26b2 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -230,18 +230,15 @@ export default class CodeGenerator { this.code.line(' * containing the CloudFormation properties of this resource.'); this.code.line(' * Used in the @aws-cdk/cloudformation-include module.'); this.code.line(' *'); - this.code.line(' * @experimental'); + this.code.line(' * @internal'); this.code.line(' */'); // eslint-disable-next-line max-len - this.code.openBlock(`public static fromCloudFormation(scope: ${CONSTRUCT_CLASS}, id: string, resourceAttributes: any, options: ${CORE}.FromCloudFormationOptions): ` + + this.code.openBlock(`public static _fromCloudFormation(scope: ${CONSTRUCT_CLASS}, id: string, resourceAttributes: any, options: ${CFN_PARSE}.FromCloudFormationOptions): ` + `${resourceName.className}`); this.code.line('resourceAttributes = resourceAttributes || {};'); - this.code.indent('const cfnParser = new cfn_parse.CfnParser({'); - this.code.line('finder: options.finder,'); - this.code.unindent('});'); if (propsType) { // translate the template properties to CDK objects - this.code.line('const resourceProperties = cfnParser.parseValue(resourceAttributes.Properties);'); + this.code.line('const resourceProperties = options.parser.parseValue(resourceAttributes.Properties);'); // translate to props, using a (module-private) factory function this.code.line(`const props = ${genspec.fromCfnFactoryName(propsType).fqn}(resourceProperties);`); // finally, instantiate the resource class @@ -252,7 +249,7 @@ export default class CodeGenerator { } // handle all non-property attributes // (retention policies, conditions, metadata, etc.) - this.code.line('cfnParser.handleAttributes(ret, resourceAttributes, id);'); + this.code.line('options.parser.handleAttributes(ret, resourceAttributes, id);'); this.code.line('return ret;'); this.code.closeBlock();