Skip to content

Commit

Permalink
feat(cfn-include): add support for retrieving Output objects from the…
Browse files Browse the repository at this point in the history
… template (#8821)

Closes #8820

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
comcalvi authored Jul 2, 2020
1 parent 8420f96 commit 0b09bbb
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 12 deletions.
20 changes: 19 additions & 1 deletion packages/@aws-cdk/cloudformation-include/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):

Expand Down
64 changes: 62 additions & 2 deletions packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -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];
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"Outputs": {
"SomeOutput": {
"Condition": "NonexistantCondition"
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -396,8 +396,8 @@ describe('CDK Include', () => {
],
},
},
"Metadata" : {
"Object1" : "Location1",
"Metadata": {
"Object1": "Location1",
"KeyRef": { "Ref": "TotallyDifferentKey" },
"KeyArn": { "Fn::GetAtt": ["TotallyDifferentKey", "Arn"] },
},
Expand All @@ -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 {
Expand Down
72 changes: 66 additions & 6 deletions packages/@aws-cdk/core/lib/cfn-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}

/**
Expand All @@ -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,
},
},
Expand Down

0 comments on commit 0b09bbb

Please sign in to comment.