Skip to content

Commit

Permalink
feat(cfn-include): handle resources not in the CloudFormation schema (#…
Browse files Browse the repository at this point in the history
…9199)

Closes  #9197

----

*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 23, 2020
1 parent 8e73608 commit d287525
Show file tree
Hide file tree
Showing 11 changed files with 154 additions and 159 deletions.
4 changes: 4 additions & 0 deletions packages/@aws-cdk/cloudformation-include/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ const bucket = s3.Bucket.fromBucketName(this, 'L2Bucket', cfnBucket.ref);
// bucket is of type s3.IBucket
```

Note that [Custom Resources](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cfn-customresource.html)
will be of type CfnResource, and hence won't need to be casted.
This holds for any resource that isn't in the CloudFormation schema.

## Conditions

If your template uses [CloudFormation Conditions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html),
Expand Down
68 changes: 26 additions & 42 deletions packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,7 @@ export class CfnInclude extends core.CfnElement {
}

const resourceAttributes: any = this.template.Resources[logicalId];
const l1ClassFqn = cfn_type_to_l1_mapping.lookup(resourceAttributes.Type);
if (!l1ClassFqn) {
// currently, we only handle types we know the L1 for -
// in the future, we might construct an instance of CfnResource instead
throw new Error(`Unrecognized CloudFormation resource type: '${resourceAttributes.Type}'`);
}

// fail early for resource attributes we don't support yet
const knownAttributes = [
'Type', 'Properties', 'Condition', 'DependsOn', 'Metadata',
Expand All @@ -329,9 +324,6 @@ export class CfnInclude extends core.CfnElement {
}
}

const [moduleName, ...className] = l1ClassFqn.split('.');
const module = require(moduleName); // eslint-disable-line @typescript-eslint/no-require-imports
const jsClassFromModule = module[className.join('.')];
const self = this;
const finder: core.ICfnFinder = {
findCondition(conditionName: string): core.CfnCondition | undefined {
Expand All @@ -353,13 +345,31 @@ export class CfnInclude extends core.CfnElement {
return this.findResource(elementName);
},
};
const options: core.FromCloudFormationOptions = {
const cfnParser = new cfn_parse.CfnParser({
finder,
};
});

const l1Instance = this.nestedStacksToInclude[logicalId]
? this.createNestedStack(logicalId, finder)
: jsClassFromModule.fromCloudFormation(this, logicalId, resourceAttributes, options);
let l1Instance: core.CfnResource;
if (this.nestedStacksToInclude[logicalId]) {
l1Instance = this.createNestedStack(logicalId, cfnParser);
} else {
const l1ClassFqn = cfn_type_to_l1_mapping.lookup(resourceAttributes.Type);
if (l1ClassFqn) {
const options: core.FromCloudFormationOptions = {
finder,
};
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);
} else {
l1Instance = new core.CfnResource(this, logicalId, {
type: resourceAttributes.Type,
properties: cfnParser.parseValue(resourceAttributes.Properties),
});
cfnParser.handleAttributes(l1Instance, resourceAttributes, logicalId);
}
}

if (this.preserveLogicalIds) {
// override the logical ID to match the original template
Expand All @@ -370,7 +380,7 @@ export class CfnInclude extends core.CfnElement {
return l1Instance;
}

private createNestedStack(nestedStackId: string, finder: core.ICfnFinder): core.CfnResource {
private createNestedStack(nestedStackId: string, cfnParser: cfn_parse.CfnParser): core.CfnResource {
const templateResources = this.template.Resources || {};
const nestedStackAttributes = templateResources[nestedStackId] || {};

Expand All @@ -384,9 +394,6 @@ export class CfnInclude extends core.CfnElement {
throw new Error('UpdatePolicy is not supported by the AWS::CloudFormation::Stack resource');
}

const cfnParser = new cfn_parse.CfnParser({
finder,
});
const nestedStackProps = cfnParser.parseValue(nestedStackAttributes.Properties);
const nestedStack = new core.NestedStack(this, nestedStackId, {
parameters: nestedStackProps.Parameters,
Expand All @@ -396,30 +403,7 @@ export class CfnInclude extends core.CfnElement {

// we know this is never undefined for nested stacks
const nestedStackResource: core.CfnResource = nestedStack.nestedStackResource!;
// handle resource attributes
const cfnOptions = nestedStackResource.cfnOptions;
cfnOptions.metadata = cfnParser.parseValue(nestedStackAttributes.Metadata);
cfnOptions.deletionPolicy = cfnParser.parseDeletionPolicy(nestedStackAttributes.DeletionPolicy);
cfnOptions.updateReplacePolicy = cfnParser.parseDeletionPolicy(nestedStackAttributes.UpdateReplacePolicy);
// handle DependsOn
nestedStackAttributes.DependsOn = nestedStackAttributes.DependsOn ?? [];
const dependencies: string[] = Array.isArray(nestedStackAttributes.DependsOn) ?
nestedStackAttributes.DependsOn : [nestedStackAttributes.DependsOn];
for (const dep of dependencies) {
const depResource = finder.findResource(dep);
if (!depResource) {
throw new Error(`nested stack '${nestedStackId}' depends on '${dep}' that doesn't exist`);
}
nestedStackResource.node.addDependency(depResource);
}
// handle Condition
if (nestedStackAttributes.Condition) {
const condition = finder.findCondition(nestedStackAttributes.Condition);
if (!condition) {
throw new Error(`nested stack '${nestedStackId}' uses Condition '${nestedStackAttributes.Condition}' that doesn't exist`);
}
cfnOptions.condition = condition;
}
cfnParser.handleAttributes(nestedStackResource, nestedStackAttributes, nestedStackId);

const propStack = this.nestedStacksToInclude[nestedStackId];
const template = new CfnInclude(nestedStack, nestedStackId, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe('CDK Include', () => {
},
},
});
}).toThrow(/nested stack 'ChildStack' uses Condition 'FakeCondition' that doesn't exist/);
}).toThrow(/Resource 'ChildStack' uses Condition 'FakeCondition' that doesn't exist/);
});

test('throws an exception when a nested stacks depends on a resource that does not exist in the template', () => {
Expand All @@ -157,7 +157,20 @@ describe('CDK Include', () => {
},
},
});
}).toThrow(/nested stack 'ChildStack' depends on 'AFakeResource' that doesn't exist/);
}).toThrow(/Resource 'ChildStack' depends on 'AFakeResource' that doesn't exist/);
});

test('throws an exception when an ID was passed in nestedStacks that is a resource type not in the CloudFormation schema', () => {
expect(() => {
new inc.CfnInclude(stack, 'Template', {
templateFile: testTemplateFilePath('custom-resource.json'),
nestedStacks: {
'CustomResource': {
templateFile: testTemplateFilePath('whatever.json'),
},
},
});
}).toThrow(/Nested Stack with logical ID 'CustomResource' is not an AWS::CloudFormation::Stack resource/);
});

test('can modify resources in nested stacks', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"Conditions": {
"AlwaysFalseCond": {
"Fn::Equals": [
{
"Ref": "AWS::Region"
},
"completely-made-up-region"
]
}
},
"Resources": {
"CustomBucket": {
"Type": "AWS::MyService::Custom",
"Condition": "AlwaysFalseCond",
"Metadata": {
"Object1": "Value1",
"Object2": "Value2"
},
"CreationPolicy": {
"AutoScalingCreationPolicy": {
"MinSuccessfulInstancesPercent" : 90
}
},
"DeletionPolicy": "Retain",
"DependsOn": [ "CustomResource" ]
},
"CustomResource": {
"Type": "AWS::MyService::AnotherCustom",
"Properties": {
"CustomProp": "CustomValue",
"CustomFuncProp": {
"Ref": "AWS::NoValue"
}
},
"UpdatePolicy": {
"AutoScalingReplacingUpdate": {
"WillReplace" : "false"
}
},
"UpdateReplacePolicy": "Retain"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Resources": {
"CustomResource": {
"Type": "AWS::MyService::Custom",
"Condition": "AlwaysFalseCond"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"Resources": {
"CustomResource": {
"Type": "AWS::CustomResource::Type"
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -444,10 +444,20 @@ describe('CDK Include', () => {
});
});

test("throws an exception when encountering a Resource type it doesn't recognize", () => {
test('can include a template with a custom resource that uses attributes', () => {
const cfnTemplate = includeTestTemplate(stack, 'custom-resource-with-attributes.json');
expect(stack).toMatchTemplate(
loadTestFileToJsObject('custom-resource-with-attributes.json'),
);

const alwaysFalseCondition = cfnTemplate.getCondition('AlwaysFalseCond');
expect(cfnTemplate.getResource('CustomBucket').cfnOptions.condition).toBe(alwaysFalseCondition);
});

test("throws an exception when a custom resource uses a Condition attribute that doesn't exist in the template", () => {
expect(() => {
includeTestTemplate(stack, 'non-existent-resource-type.json');
}).toThrow(/Unrecognized CloudFormation resource type: 'AWS::FakeService::DoesNotExist'/);
includeTestTemplate(stack, 'custom-resource-with-bad-condition.json');
}).toThrow(/Resource 'CustomResource' uses Condition 'AlwaysFalseCond' that doesn't exist/);
});

test('can ingest a template that contains outputs and modify them', () => {
Expand Down
39 changes: 36 additions & 3 deletions packages/@aws-cdk/core/lib/cfn-parse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Fn } from './cfn-fn';
import { Aws } from './cfn-pseudo';
import { CfnResource } from './cfn-resource';
import {
CfnAutoScalingReplacingUpdate, CfnAutoScalingRollingUpdate, CfnAutoScalingScheduledAction, CfnCodeDeployLambdaAliasUpdate,
CfnCreationPolicy, CfnDeletionPolicy, CfnResourceAutoScalingCreationPolicy, CfnResourceSignal, CfnUpdatePolicy,
Expand Down Expand Up @@ -169,7 +170,39 @@ export class CfnParser {
this.options = options;
}

public parseCreationPolicy(policy: any): CfnCreationPolicy | undefined {
public handleAttributes(resource: CfnResource, resourceAttributes: any, logicalId: string): void {
const finder = this.options.finder;
const cfnOptions = resource.cfnOptions;

cfnOptions.creationPolicy = this.parseCreationPolicy(resourceAttributes.CreationPolicy);
cfnOptions.updatePolicy = this.parseUpdatePolicy(resourceAttributes.UpdatePolicy);
cfnOptions.deletionPolicy = this.parseDeletionPolicy(resourceAttributes.DeletionPolicy);
cfnOptions.updateReplacePolicy = this.parseDeletionPolicy(resourceAttributes.UpdateReplacePolicy);
cfnOptions.metadata = this.parseValue(resourceAttributes.Metadata);

// handle Condition
if (resourceAttributes.Condition) {
const condition = finder.findCondition(resourceAttributes.Condition);
if (!condition) {
throw new Error(`Resource '${logicalId}' uses Condition '${resourceAttributes.Condition}' that doesn't exist`);
}
cfnOptions.condition = condition;
}

// handle DependsOn
resourceAttributes.DependsOn = resourceAttributes.DependsOn ?? [];
const dependencies: string[] = Array.isArray(resourceAttributes.DependsOn) ?
resourceAttributes.DependsOn : [resourceAttributes.DependsOn];
for (const dep of dependencies) {
const depResource = finder.findResource(dep);
if (!depResource) {
throw new Error(`Resource '${logicalId}' depends on '${dep}' that doesn't exist`);
}
resource.node.addDependency(depResource);
}
}

private parseCreationPolicy(policy: any): CfnCreationPolicy | undefined {
if (typeof policy !== 'object') { return undefined; }

// change simple JS values to their CDK equivalents
Expand Down Expand Up @@ -198,7 +231,7 @@ export class CfnParser {
}
}

public parseUpdatePolicy(policy: any): CfnUpdatePolicy | undefined {
private parseUpdatePolicy(policy: any): CfnUpdatePolicy | undefined {
if (typeof policy !== 'object') { return undefined; }

// change simple JS values to their CDK equivalents
Expand Down Expand Up @@ -254,7 +287,7 @@ export class CfnParser {
}
}

public parseDeletionPolicy(policy: any): CfnDeletionPolicy | undefined {
private parseDeletionPolicy(policy: any): CfnDeletionPolicy | undefined {
switch (policy) {
case null: return undefined;
case undefined: return undefined;
Expand Down
72 changes: 0 additions & 72 deletions packages/@aws-cdk/core/test/test.cfn-parse.ts

This file was deleted.

Loading

0 comments on commit d287525

Please sign in to comment.