diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md index 68f7b0e2a8b12..01be06067dde4 100644 --- a/packages/@aws-cdk/cloudformation-include/README.md +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -124,6 +124,24 @@ and any changes you make to it will be reflected in the resulting template: condition.expression = core.Fn.conditionEquals(1, 2); ``` +## Outputs + +If your template uses [CloudFormation Outputs](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html), +you can retrieve them from your template: + +```typescript +import * as core from '@aws-cdk/core'; + +const output: core.CfnOutput = cfnTemplate.getOutput('MyOutput'); +``` + +The `CfnOutput` object is mutable, +and any changes you make to it will be reflected in the resulting template: + +```typescript +output.value = cfnBucket.attrArn; +``` + ## Known limitations This module is still in its early, experimental stage, @@ -135,7 +153,7 @@ All items unchecked below are currently not supported. - [x] Resources - [x] Parameters - [x] Conditions -- [ ] Outputs +- [x] Outputs ### [Resource attributes](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-product-attribute-reference.html): diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts index 49c5a769de73e..035631fd282d5 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -24,6 +24,7 @@ export class CfnInclude extends core.CfnElement { private readonly conditions: { [conditionName: string]: core.CfnCondition } = {}; private readonly resources: { [logicalId: string]: core.CfnResource } = {}; private readonly parameters: { [logicalId: string]: core.CfnParameter } = {}; + private readonly outputs: { [logicalId: string]: core.CfnOutput } = {}; private readonly template: any; private readonly preserveLogicalIds: boolean; @@ -50,6 +51,12 @@ export class CfnInclude extends core.CfnElement { for (const logicalId of Object.keys(this.template.Resources || {})) { this.getOrCreateResource(logicalId); } + + const outputScope = new core.Construct(this, '$Ouputs'); + + for (const logicalId of Object.keys(this.template.Outputs || {})) { + this.createOutput(logicalId, outputScope); + } } /** @@ -112,14 +119,32 @@ export class CfnInclude extends core.CfnElement { return ret; } + /** + * Returns the CfnOutput object from the 'Outputs' + * 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, + * throws an exception. + * + * @param logicalId the name of the output to retrieve + */ + public getOutput(logicalId: string): core.CfnOutput { + const ret = this.outputs[logicalId]; + if (!ret) { + throw new Error(`Output with logical ID '${logicalId}' was not found in the template`); + } + return ret; + } + /** @internal */ public _toCloudFormation(): object { const ret: { [section: string]: any } = {}; for (const section of Object.keys(this.template)) { // render all sections of the template unchanged, - // except Conditions, Resources, and Parameters, which will be taken care of by the created L1s - if (section !== 'Conditions' && section !== 'Resources' && section !== 'Parameters') { + // 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]; } } @@ -152,6 +177,41 @@ export class CfnInclude extends core.CfnElement { this.parameters[logicalId] = cfnParameter; } + private createOutput(logicalId: string, scope: core.Construct): void { + const self = this; + const outputAttributes = new cfn_parse.CfnParser({ + finder: { + findResource(lId): core.CfnResource | undefined { + return self.resources[lId]; + }, + findRefTarget(elementName: string): core.CfnElement | undefined { + return self.resources[elementName] ?? self.parameters[elementName]; + }, + findCondition(): undefined { + return undefined; + }, + }, + }).parseValue(this.template.Outputs[logicalId]); + const cfnOutput = new core.CfnOutput(scope, logicalId, { + value: outputAttributes.Value, + description: outputAttributes.Description, + exportName: outputAttributes.Export ? outputAttributes.Export.Name : undefined, + condition: (() => { + if (!outputAttributes.Condition) { + return undefined; + } else if (this.conditions[outputAttributes.Condition]) { + return self.getCondition(outputAttributes.Condition); + } + + throw new Error(`Output with name '${logicalId}' refers to a Condition with name\ + '${outputAttributes.Condition}' which was not found in this template`); + })(), + }); + + cfnOutput.overrideLogicalId(logicalId); + this.outputs[logicalId] = cfnOutput; + } + private createCondition(conditionName: string): void { // ToDo condition expressions can refer to other conditions - // will be important when implementing preserveLogicalIds=false 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 eb04ef059e15e..0d1ba70795e5d 100644 --- a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts @@ -76,6 +76,12 @@ describe('CDK Include', () => { includeTestTemplate(stack, 'getting-attribute-of-a-non-existent-resource.json'); }).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", () => { + 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/); + }); }); function includeTestTemplate(scope: core.Construct, testTemplate: string): inc.CfnInclude { diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/output-referencing-nonexistant-condition.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/output-referencing-nonexistant-condition.json new file mode 100644 index 0000000000000..fbb06694b8ae6 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/output-referencing-nonexistant-condition.json @@ -0,0 +1,7 @@ +{ + "Outputs": { + "SomeOutput": { + "Condition": "NonexistantCondition" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/outputs-with-references.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/outputs-with-references.json new file mode 100644 index 0000000000000..206b1d1d15009 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/outputs-with-references.json @@ -0,0 +1,45 @@ +{ + "Conditions": { + "AlwaysFalseCond": { + "Fn::Equals": [ + { + "Ref": "AWS::Region" + }, + "completely-made-up-region" + ] + } + }, + "Parameters": { + "MyParam": { + "Type": "String" + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket" + }, + "Output1": { + "Type": "AWS::S3::Bucket" + } + }, + "Outputs": { + "Output1": { + "Value": { + "Fn::Join": [ "", [ + { "Ref": "MyParam" }, + { "Fn::GetAtt": [ "Bucket", "Arn" ] } ] + ] + }, + "Description": { + "Ref": "Bucket" + }, + "Condition": "AlwaysFalseCond", + "Export": { + "Name": "Bucket" + } + }, + "OutputWithNoCondition": { + "Value": "some-value" + } + } +} \ 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 6994a77f6bff1..57e6d0470dcb9 100644 --- a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -251,7 +251,7 @@ describe('CDK Include', () => { ); }); - test("correctly parses templates with parameters", () => { + test('correctly parses templates with parameters', () => { const cfnTemplate = includeTestTemplate(stack, 'bucket-with-parameters.json'); const param = cfnTemplate.getParameter('BucketName'); new s3.CfnBucket(stack, 'NewBucket', { @@ -396,8 +396,8 @@ describe('CDK Include', () => { ], }, }, - "Metadata" : { - "Object1" : "Location1", + "Metadata": { + "Object1": "Location1", "KeyRef": { "Ref": "TotallyDifferentKey" }, "KeyArn": { "Fn::GetAtt": ["TotallyDifferentKey", "Arn"] }, }, @@ -414,6 +414,73 @@ describe('CDK Include', () => { includeTestTemplate(stack, 'non-existent-resource-type.json'); }).toThrow(/Unrecognized CloudFormation resource type: 'AWS::FakeService::DoesNotExist'/); }); + + test('can ingest a template that contains outputs and modify them', () => { + const cfnTemplate = includeTestTemplate(stack, 'outputs-with-references.json'); + + const output = cfnTemplate.getOutput('Output1'); + output.value = 'a mutated value'; + output.description = undefined; + output.exportName = 'an export'; + output.condition = new core.CfnCondition(stack, 'MyCondition', { + expression: core.Fn.conditionIf('AlwaysFalseCond', core.Aws.NO_VALUE, true), + }); + + const originalTemplate = loadTestFileToJsObject('outputs-with-references.json'); + + expect(stack).toMatchTemplate({ + "Conditions": { + ...originalTemplate.Conditions, + "MyCondition": { + "Fn::If": [ + "AlwaysFalseCond", + { "Ref": "AWS::NoValue" }, + true, + ], + }, + }, + "Parameters": { + ...originalTemplate.Parameters, + }, + "Resources": { + ...originalTemplate.Resources, + }, + "Outputs": { + "Output1": { + "Value": "a mutated value", + "Export": { + "Name": "an export", + }, + "Condition": "MyCondition", + }, + "OutputWithNoCondition": { + "Value": "some-value", + }, + }, + }); + }); + + test('can ingest a template that contains outputs and get those outputs', () => { + const cfnTemplate = includeTestTemplate(stack, 'outputs-with-references.json'); + const output = cfnTemplate.getOutput('Output1'); + + expect(output.condition).toBe(cfnTemplate.getCondition('AlwaysFalseCond')); + expect(output.description).toBeDefined(); + expect(output.value).toBeDefined(); + expect(output.exportName).toBeDefined(); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('outputs-with-references.json'), + ); + }); + + test("throws an exception when attempting to retrieve an Output that doesn't exist", () => { + const cfnTemplate = includeTestTemplate(stack, 'outputs-with-references.json'); + + expect(() => { + cfnTemplate.getOutput('FakeOutput'); + }).toThrow(/Output with logical ID 'FakeOutput' was not found in the template/); + }); }); interface IncludeTestTemplateProps { diff --git a/packages/@aws-cdk/core/lib/cfn-output.ts b/packages/@aws-cdk/core/lib/cfn-output.ts index d99622badb1ef..387c5216ead91 100644 --- a/packages/@aws-cdk/core/lib/cfn-output.ts +++ b/packages/@aws-cdk/core/lib/cfn-output.ts @@ -36,10 +36,10 @@ export interface CfnOutputProps { } export class CfnOutput extends CfnElement { - private readonly _description?: string; - private readonly _condition?: CfnCondition; - private readonly _value?: any; - private readonly _export?: string; + private _description?: string; + private _condition?: CfnCondition; + private _value?: any; + private _exportName?: string; /** * Creates an CfnOutput value for this stack. @@ -56,7 +56,67 @@ export class CfnOutput extends CfnElement { this._description = props.description; this._value = props.value; this._condition = props.condition; - this._export = props.exportName; + this._exportName = props.exportName; + } + + /** + * Returns the description of this Output + */ + public get description() { + return this._description; + } + + /** + * Sets this output's description to the parameter + * @param description the description to update this Output's description to + */ + public set description(description: string | undefined) { + this._description = description; + } + + /** + * Returns the value of this Output + */ + public get value() { + return this._value; + } + + /** + * Sets this output's value to the parameter + * @param value the value to update this Output's value to + */ + public set value(value: any) { + this._value = value; + } + + /** + * Returns the condition of this Output + */ + public get condition() { + return this._condition; + } + + /** + * Sets this output's condition to the parameter + * @param condition the condition to update this Output's condition to + */ + public set condition(condition: CfnCondition | undefined) { + this._condition = condition; + } + + /** + * Returns the export of this Output + */ + public get exportName() { + return this._exportName; + } + + /** + * Sets this output's export to the parameter + * @param exportName the export to update this Output's export to + */ + public set exportName(exportName: string | undefined) { + this._exportName = exportName; } /** @@ -68,7 +128,7 @@ export class CfnOutput extends CfnElement { [this.logicalId]: { Description: this._description, Value: this._value, - Export: this._export != null ? { Name: this._export } : undefined, + Export: this._exportName != null ? { Name: this._exportName } : undefined, Condition: this._condition ? this._condition.logicalId : undefined, }, },