diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md index 160bddd94cf6c..0e08b7f5fb745 100644 --- a/packages/@aws-cdk/cloudformation-include/README.md +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -218,47 +218,3 @@ bucketReadRole.addToPolicy(new iam.PolicyStatement({ resources: [bucket.attrArn], })); ``` - -## Known limitations - -This module is still in its early, experimental stage, -and so does not implement all features of CloudFormation templates. -All items unchecked below are currently not supported. - -### Ability to retrieve CloudFormation objects from the template: - -- [x] Resources -- [x] Parameters -- [x] Conditions -- [x] Outputs - -### [Resource attributes](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-product-attribute-reference.html): - -- [x] Properties -- [x] Condition -- [x] DependsOn -- [x] CreationPolicy -- [x] UpdatePolicy -- [x] UpdateReplacePolicy -- [x] DeletionPolicy -- [x] Metadata - -### [CloudFormation functions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html): - -- [x] Ref -- [x] Fn::GetAtt -- [x] Fn::Join -- [x] Fn::If -- [x] Fn::And -- [x] Fn::Equals -- [x] Fn::Not -- [x] Fn::Or -- [x] Fn::Base64 -- [x] Fn::Cidr -- [x] Fn::FindInMap -- [x] Fn::GetAZs -- [x] Fn::ImportValue -- [x] Fn::Select -- [x] Fn::Split -- [ ] Fn::Sub -- [x] Fn::Transform diff --git a/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts b/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts index dd58b76777d5d..65a05101bcb90 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts @@ -31,11 +31,9 @@ function makeTagForCfnIntrinsic( } const shortForms: yaml_types.Schema.CustomTag[] = [ - 'Base64', 'Cidr', 'FindInMap', 'GetAZs', 'ImportValue', 'Join', + 'Base64', 'Cidr', 'FindInMap', 'GetAZs', 'ImportValue', 'Join', 'Sub', 'Select', 'Split', 'Transform', 'And', 'Equals', 'If', 'Not', 'Or', ].map(name => makeTagForCfnIntrinsic(name)).concat( - // ToDo: special logic for ImportValue will be needed when support for Fn::Sub is added. See - // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html makeTagForCfnIntrinsic('Ref', false), makeTagForCfnIntrinsic('GetAtt', true, (_doc: yaml.Document, cstNode: yaml_cst.CST.Node): any => { // The position of the leftmost period and opening bracket tell us what syntax is being used 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 ef93cd9809d82..113e58773a242 100644 --- a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts @@ -100,6 +100,18 @@ describe('CDK Include', () => { 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 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'); + }).toThrow(/Element referenced in Fn::Sub expression with logical ID: 'AFakeResource' was not found in the template/); + }); + + test('throws a validation exception when Fn::Sub has an empty ${} reference', () => { + expect(() => { + includeTestTemplate(stack, 'fn-sub-${}-only.json'); + }).toThrow(/Element referenced in Fn::Sub expression with logical ID: '' was not found in the template/); + }); }); function includeTestTemplate(scope: core.Construct, testTemplate: string): inc.CfnInclude { diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-brace-edges.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-brace-edges.json new file mode 100644 index 0000000000000..b8ef634ac6cd0 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-brace-edges.json @@ -0,0 +1,55 @@ +{ + "Resources": { + "Bucket": { + "Type": "Custom::ManyStrings", + "Properties": { + "SymbolsOnly": { + "DollarSign": { + "Fn::Sub": "$" + }, + "OpeningBrace": { + "Fn::Sub": "{" + }, + "ClosingBrace": { + "Fn::Sub": "}" + }, + "DollarOpeningBrace": { + "Fn::Sub": "${" + }, + "DollarClosingBrace": { + "Fn::Sub": "$}" + }, + "OpeningBraceDollar": { + "Fn::Sub": "{$" + }, + "ClosingBraceDollar": { + "Fn::Sub": "}$" + } + }, + "SymbolsAndResources": { + "DollarSign": { + "Fn::Sub": "DoesNotExist$DoesNotExist" + }, + "OpeningBrace": { + "Fn::Sub": "DoesNotExist{DoesNotExist" + }, + "ClosingBrace": { + "Fn::Sub": "DoesNotExist}DoesNotExist" + }, + "DollarOpeningBrace": { + "Fn::Sub": "DoesNotExist${DoesNotExist" + }, + "DollarClosingBrace": { + "Fn::Sub": "DoesNotExist$}DoesNotExist" + }, + "OpeningBraceDollar": { + "Fn::Sub": "DoesNotExist{$DoesNotExist" + }, + "ClosingBraceDollar": { + "Fn::Sub": "DoesNotExist}$DoesNotExist" + } + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-escaping.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-escaping.json new file mode 100644 index 0000000000000..915a65819aec7 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-escaping.json @@ -0,0 +1,10 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Fn::Sub": "some-bucket${!AWS::AccountId}7896${ ! DoesNotExist}${!Immediate}234" } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-map-dotted-attributes.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-map-dotted-attributes.json new file mode 100644 index 0000000000000..5bc772b8f5860 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-map-dotted-attributes.json @@ -0,0 +1,25 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "${ELB.SourceSecurityGroup.GroupName}" + } + } + }, + "ELB": { + "Type": "AWS::ElasticLoadBalancing::LoadBalancer", + "Properties": { + "AvailabilityZones": [ + "us-east-1a" + ], + "Listeners": [{ + "LoadBalancerPort": "80", + "InstancePort": "80", + "Protocol": "HTTP" + }] + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-override.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-override.json new file mode 100644 index 0000000000000..cdaa623bfd7fa --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-override.json @@ -0,0 +1,16 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "bucket" + } + }, + "AnotherBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Fn::Sub": "${Bucket}-${!Bucket}-${Bucket.DomainName}" } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-parsing-edges.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-parsing-edges.json new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow-attribute.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow-attribute.json new file mode 100644 index 0000000000000..8401ee9a79ccb --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow-attribute.json @@ -0,0 +1,20 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": [ + "${AnotherBucket.DomainName}", + { + "AnotherBucket": "whatever" + } + ] + } + } + }, + "AnotherBucket": { + "Type": "AWS::S3::Bucket" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow.json new file mode 100644 index 0000000000000..6e5cdbee0be2c --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-shadow.json @@ -0,0 +1,20 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": [ + "${AnotherBucket}", + { + "AnotherBucket": { "Ref" : "AnotherBucket" } + } + ] + } + } + }, + "AnotherBucket": { + "Type": "AWS::S3::Bucket" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-string.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-string.json new file mode 100644 index 0000000000000..2936a59bb55fc --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/fn-sub-string.json @@ -0,0 +1,16 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "bucket" + } + }, + "AnotherBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Fn::Sub": "1-${AWS::Region}-foo-${Bucket}-${!Literal}-${Bucket.DomainName}-${AWS::Region}" } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/fn-sub-${}-only.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/fn-sub-${}-only.json new file mode 100644 index 0000000000000..87f9556e5a6b0 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/fn-sub-${}-only.json @@ -0,0 +1,12 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "${}" + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/fn-sub-key-not-in-template-string.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/fn-sub-key-not-in-template-string.json new file mode 100644 index 0000000000000..c5e9ff5b13b8d --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/fn-sub-key-not-in-template-string.json @@ -0,0 +1,10 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "AccessControl": { "Fn::Sub": "${AFakeResource}" } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/invalid/short-form-import-sub.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/invalid/short-form-import-sub.yaml new file mode 100644 index 0000000000000..899904f61a8cf --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/invalid/short-form-import-sub.yaml @@ -0,0 +1,7 @@ +Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + !ImportValue + !Sub ${AWS::Region} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/long-form-vpc.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/long-form-vpc.yaml index de8b072887d23..f32def7fd072a 100644 --- a/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/long-form-vpc.yaml +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/long-form-vpc.yaml @@ -42,3 +42,11 @@ Resources: Location: location, AnotherParameter: Fn::Base64: AnotherValue + AccessControl: + Fn::ImportValue: + Fn::Sub: + - "${Region}-foo-${!Immediate}-foo-${Vpc}-${Vpc.Id}-${Name}" + - Name: + Ref: Vpc + Region: + Fn::Base64: AWS::Region diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-fnsub-string.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-fnsub-string.yaml new file mode 100644 index 0000000000000..72ccedb2c61c9 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-fnsub-string.yaml @@ -0,0 +1,11 @@ +Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::Sub: some-bucket${!AWS::AccountId}7896${ ! AWS::Region}1-1${!Immediate}234 + AnotherBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + !Sub 1-${AWS::Region}-foo-${Bucket}-${!Literal}-${Bucket.DomainName}-${AWS::Region} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-sub-map.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-sub-map.yaml new file mode 100644 index 0000000000000..80450b205abba --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-sub-map.yaml @@ -0,0 +1,15 @@ +Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + !Sub + - "${Region}-foo-${!Immediate}-foo-${AnotherBucket}-${AnotherBucket.DomainName}-${Name}" + - Name: + Ref: AnotherBucket + Region: + Fn::Base64: AWS::Region + AnotherBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: another-bucket 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 912525b7c369f..07529248be53b 100644 --- a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -192,6 +192,83 @@ describe('CDK Include', () => { ); }); + test('can ingest a template with Fn::Sub in string form with escaped and unescaped references and output it unchanged', () => { + includeTestTemplate(stack, 'fn-sub-string.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('fn-sub-string.json'), + ); + }); + + test('can parse the string argument Fn::Sub with escaped references that contain whitespace', () => { + includeTestTemplate(stack, 'fn-sub-escaping.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('fn-sub-escaping.json'), + ); + }); + + test('can ingest a template with Fn::Sub in map form and output it unchanged', () => { + includeTestTemplate(stack, 'fn-sub-map-dotted-attributes.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('fn-sub-map-dotted-attributes.json'), + ); + }); + + test('can ingest a template with Fn::Sub shadowing a logical ID from the template and output it unchanged', () => { + includeTestTemplate(stack, 'fn-sub-shadow.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('fn-sub-shadow.json'), + ); + }); + + test('can ingest a template with Fn::Sub attribute expression shadowing a logical ID from the template, and output it unchanged', () => { + includeTestTemplate(stack, 'fn-sub-shadow-attribute.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('fn-sub-shadow-attribute.json'), + ); + }); + + test('can modify resources used in Fn::Sub in map form references and see the changes in the template', () => { + const cfnTemplate = includeTestTemplate(stack, 'fn-sub-shadow.json'); + + cfnTemplate.getResource('AnotherBucket').overrideLogicalId('NewBucket'); + + expect(stack).toHaveResourceLike('AWS::S3::Bucket', { + "BucketName": { + "Fn::Sub": [ + "${AnotherBucket}", + { + "AnotherBucket": { "Ref": "NewBucket" }, + }, + ], + }, + }); + }); + + test('can modify resources used in Fn::Sub in string form and see the changes in the template', () => { + const cfnTemplate = includeTestTemplate(stack, 'fn-sub-override.json'); + + cfnTemplate.getResource('Bucket').overrideLogicalId('NewBucket'); + + expect(stack).toHaveResourceLike('AWS::S3::Bucket', { + "BucketName": { + "Fn::Sub": "${NewBucket}-${!Bucket}-${NewBucket.DomainName}", + }, + }); + }); + + test('can ingest a template with Fn::Sub with brace edge cases and output it unchanged', () => { + includeTestTemplate(stack, 'fn-sub-brace-edges.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('fn-sub-brace-edges.json'), + ); + }); + test('can ingest a template with a Ref expression for an array value, and output it unchanged', () => { includeTestTemplate(stack, 'ref-array-property.json'); diff --git a/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts index 01bba1610a9b5..11180842bdb34 100644 --- a/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts @@ -323,6 +323,28 @@ describe('CDK Include', () => { loadTestFileToJsObject('long-form-subnet.yaml'), ); }); + + test('can ingest a YAML tempalte with Fn::Sub in string form and output it unchanged', () => { + includeTestTemplate(stack, 'short-form-fnsub-string.yaml'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('short-form-fnsub-string.yaml'), + ); + }); + + test('can ingest a YAML tmeplate with Fn::Sub in map form and output it unchanged', () => { + includeTestTemplate(stack, 'short-form-sub-map.yaml'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('short-form-sub-map.yaml'), + ); + }); + + test('the parser throws an error on a YAML tmeplate with short form import value that uses short form sub', () => { + expect(() => { + includeTestTemplate(stack, 'invalid/short-form-import-sub.yaml'); + }).toThrow(/A node can have at most one tag/); + }); }); function includeTestTemplate(scope: core.Construct, testTemplate: string): inc.CfnInclude { diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts index 6c880b80cd420..c821b66971073 100644 --- a/packages/@aws-cdk/core/lib/cfn-parse.ts +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -419,6 +419,20 @@ export class CfnParser { const value = this.parseValue(object[key]); return Fn.conditionOr(...value); } + case 'Fn::Sub': { + const value = this.parseValue(object[key]); + let fnSubString: string; + let map: { [key: string]: any } | undefined; + if (typeof value === 'string') { + fnSubString = value; + map = undefined; + } else { + fnSubString = value[0]; + map = value[1]; + } + + return Fn.sub(this.parseFnSubString(fnSubString, map), map); + } case 'Condition': { // a reference to a Condition from another Condition const condition = this.options.finder.findCondition(object[key]); @@ -446,6 +460,51 @@ export class CfnParser { ? key : undefined; } + + private parseFnSubString(value: string, map: { [key: string]: any } = {}): string { + const leftBrace = value.indexOf('${'); + const rightBrace = value.indexOf('}') + 1; + // don't include left and right braces when searching for the target of the reference + if (leftBrace === -1 || leftBrace >= rightBrace) { + return value; + } + + const leftHalf = value.substring(0, leftBrace); + const rightHalf = value.substring(rightBrace); + const refTarget = value.substring(leftBrace + 2, rightBrace - 1).trim(); + if (refTarget[0] === '!') { + return value.substring(0, rightBrace) + this.parseFnSubString(rightHalf, map); + } + + // lookup in map + if (refTarget in map) { + return leftHalf + '${' + refTarget + '}' + this.parseFnSubString(rightHalf, map); + } + + // since it's not in the map, check if it's a pseudo parameter + const specialRef = specialCaseSubRefs(refTarget); + if (specialRef) { + return leftHalf + specialRef + this.parseFnSubString(rightHalf, map); + } + + const dotIndex = refTarget.indexOf('.'); + const isRef = dotIndex === -1; + if (isRef) { + const refElement = this.options.finder.findRefTarget(refTarget); + if (!refElement) { + throw new Error(`Element referenced in Fn::Sub expression with logical ID: '${refTarget}' was not found in the template`); + } + return leftHalf + CfnReference.for(refElement, 'Ref', true).toString() + this.parseFnSubString(rightHalf, map); + } else { + const targetId = refTarget.substring(0, dotIndex); + const refResource = this.options.finder.findResource(targetId); + if (!refResource) { + throw new Error(`Resource referenced in Fn::Sub expression with logical ID: '${targetId}' was not found in the template`); + } + const attribute = refTarget.substring(dotIndex + 1); + return leftHalf + CfnReference.for(refResource, attribute, true).toString() + this.parseFnSubString(rightHalf, map); + } + } } function specialCaseRefs(value: any): any { @@ -462,6 +521,10 @@ function specialCaseRefs(value: any): any { } } +function specialCaseSubRefs(value: string): string | undefined { + return value.indexOf('::') === -1 ? undefined: '${' + value + '}'; +} + function undefinedIfAllValuesAreEmpty(object: object): object | undefined { return Object.values(object).some(v => v !== undefined) ? object : undefined; } diff --git a/packages/@aws-cdk/core/lib/private/cfn-reference.ts b/packages/@aws-cdk/core/lib/private/cfn-reference.ts index cd28a221958c5..491232e344840 100644 --- a/packages/@aws-cdk/core/lib/private/cfn-reference.ts +++ b/packages/@aws-cdk/core/lib/private/cfn-reference.ts @@ -33,10 +33,15 @@ export class CfnReference extends Reference { * important that the state isn't lost if it's lazily created, like so: * * Lazy.stringValue({ produce: () => new CfnReference(...) }) + * + * If fnSub is true, then this reference will resolve as ${logicalID}. + * This allows cloudformation-include to correctly handle Fn::Sub. */ - public static for(target: CfnElement, attribute: string) { - return CfnReference.singletonReference(target, attribute, () => { - const cfnIntrinsic = attribute === 'Ref' ? { Ref: target.logicalId } : { 'Fn::GetAtt': [ target.logicalId, attribute ]}; + public static for(target: CfnElement, attribute: string, fnSub: boolean = false) { + return CfnReference.singletonReference(target, attribute, fnSub, () => { + const cfnIntrinsic = fnSub + ? ('${' + target.logicalId + (attribute === 'Ref' ? '' : `.${attribute}`) + '}') + : (attribute === 'Ref' ? { Ref: target.logicalId } : { 'Fn::GetAtt': [target.logicalId, attribute] }); return new CfnReference(cfnIntrinsic, attribute, target); }); } @@ -45,7 +50,7 @@ export class CfnReference extends Reference { * Return a CfnReference that references a pseudo referencd */ public static forPseudo(pseudoName: string, scope: Construct) { - return CfnReference.singletonReference(scope, `Pseudo:${pseudoName}`, () => { + return CfnReference.singletonReference(scope, `Pseudo:${pseudoName}`, false, () => { const cfnIntrinsic = { Ref: pseudoName }; return new CfnReference(cfnIntrinsic, pseudoName, scope); }); @@ -57,18 +62,20 @@ export class CfnReference extends Reference { private static referenceTable = new Map>(); /** - * Get or create the table + * Get or create the table. + * Passing fnSub = true allows cloudformation-include to correctly handle Fn::Sub. */ - private static singletonReference(target: Construct, attribKey: string, fresh: () => CfnReference) { + private static singletonReference(target: Construct, attribKey: string, fnSub: boolean, fresh: () => CfnReference) { let attribs = CfnReference.referenceTable.get(target); if (!attribs) { attribs = new Map(); CfnReference.referenceTable.set(target, attribs); } - let ref = attribs.get(attribKey); + const cacheKey = attribKey + (fnSub ? 'Fn::Sub' : ''); + let ref = attribs.get(cacheKey); if (!ref) { ref = fresh(); - attribs.set(attribKey, ref); + attribs.set(cacheKey, ref); } return ref; }