Skip to content

Commit

Permalink
feat(cfn-include): add support for nested stacks (aws#8980)
Browse files Browse the repository at this point in the history
Fixes aws#8978

----

*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 16, 2020
1 parent fab7e28 commit bf12456
Show file tree
Hide file tree
Showing 24 changed files with 1,292 additions and 5 deletions.
86 changes: 86 additions & 0 deletions packages/@aws-cdk/cloudformation-include/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,92 @@ and any changes you make to it will be reflected in the resulting template:
output.value = cfnBucket.attrArn;
```

## Nested Stacks

This module also support templates that use [nested stacks](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-nested-stacks.html).

For example, if you have the following parent template:

```json
{
"Resources": {
"ChildStack": {
"Type": "AWS::CloudFormation::Stack",
"Properties": {
"TemplateURL": "https://my-s3-template-source.s3.amazonaws.com/child-import-stack.json",
"Parameters": {
"MyBucketParameter": "my-bucket-name"
}
}
}
}
}
```

where the child template pointed to by `https://my-s3-template-source.s3.amazonaws.com/child-import-stack.json` is:

```json
{
"Parameters": {
"MyBucketParameter": {
"Type": "String",
"Default": "default-bucket-param-name"
}
},
"Resources": {
"BucketImport": {
"Type": "AWS::S3::Bucket",
"Properties": {
"BucketName": {
"Ref": "MyBucketParameter"
}
}
}
}
}
```

You can include both the parent stack and the nested stack in your CDK Application as follows:

```typescript
const parentTemplate = new inc.CfnInclude(stack, 'ParentStack', {
templateFile: 'path/to/my-parent-template.json',
nestedStacks: {
'ChildStack': {
templateFile: 'path/to/my-nested-template.json',
},
},
});
```

Now you can access the ChildStack nested stack and included template with:

```typescript
const includedChildStack = parentTemplate.getNestedStack('ChildStack');
const childStack: core.NestedStack = includedChildStack.stack;
const childStackTemplate: cfn_inc.CfnInclude = includedChildStack.includedTemplate;
```

Now you can reference resources from `ChildStack` and modify them like any other included template:

```typescript
const bucket = childStackTemplate.getResource('MyBucket') as s3.CfnBucket;
bucket.bucketName = 'my-new-bucket-name';

const bucketReadRole = new iam.Role(childStack, 'MyRole', {
assumedBy: new iam.AccountRootPrincipal(),
});

bucketReadRole.addToPolicy(new iam.PolicyStatement({
actions: [
's3:GetObject*',
's3:GetBucket*',
's3:List*',
],
resources: [bucket.attrArn],
}));
```

## Known limitations

This module is still in its early, experimental stage,
Expand Down
126 changes: 125 additions & 1 deletion packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,36 @@ export interface CfnIncludeProps {
* Both JSON and YAML template formats are supported.
*/
readonly templateFile: string;

/**
* Specifies the template files that define nested stacks that should be included.
*
* If your template specifies a stack that isn't included here, it won't be created as a NestedStack
* resource, and it won't be accessible from {@link CfnInclude.getNestedStack}.
*
* 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.
*/
readonly nestedStacks?: { [stackName: string]: CfnIncludeProps };
}

/**
* The type returned from {@link CfnInclude.getNestedStack}.
* Contains both the NestedStack object and
* CfnInclude representations of the child stack.
*/
export interface IncludedNestedStack {
/**
* The NestedStack object which respresents the scope of the template.
*/
readonly stack: core.NestedStack;

/**
* The CfnInclude that respresents the template, which can
* be used to access Resources and other template elements.
*/
readonly includedTemplate: CfnInclude;
}

/**
Expand All @@ -25,6 +55,8 @@ export class CfnInclude extends core.CfnElement {
private readonly resources: { [logicalId: string]: core.CfnResource } = {};
private readonly parameters: { [logicalId: string]: core.CfnParameter } = {};
private readonly outputs: { [logicalId: string]: core.CfnOutput } = {};
private readonly nestedStacks: { [logicalId: string]: IncludedNestedStack } = {};
private readonly nestedStacksToInclude: { [name: string]: CfnIncludeProps };
private readonly template: any;
private readonly preserveLogicalIds: boolean;

Expand All @@ -47,11 +79,20 @@ export class CfnInclude extends core.CfnElement {
this.createCondition(conditionName);
}

this.nestedStacksToInclude = props.nestedStacks || {};

// instantiate all resources as CDK L1 objects
for (const logicalId of Object.keys(this.template.Resources || {})) {
this.getOrCreateResource(logicalId);
}

// verify that all nestedStacks have been instantiated
for (const nestedStackId of Object.keys(props.nestedStacks || {})) {
if (!(nestedStackId in this.resources)) {
throw new Error(`Nested Stack with logical ID '${nestedStackId}' was not found in the template`);
}
}

const outputScope = new core.Construct(this, '$Ouputs');

for (const logicalId of Object.keys(this.template.Outputs || {})) {
Expand Down Expand Up @@ -137,6 +178,24 @@ export class CfnInclude extends core.CfnElement {
return ret;
}

/**
* Returns the NestedStack with name logicalId.
* For a nested stack to be returned by this method, it must be specified in the {@link CfnIncludeProps.nestedStacks}
* @param logicalId the ID of the stack to retrieve, as it appears in the template.
*/
public getNestedStack(logicalId: string): IncludedNestedStack {
if (!this.nestedStacks[logicalId]) {
if (!this.template.Resources[logicalId]) {
throw new Error(`Nested Stack with logical ID '${logicalId}' was not found in the template`);
} else if (this.template.Resources[logicalId].Type !== 'AWS::CloudFormation::Stack') {
throw new Error(`Resource with logical ID '${logicalId}' is not a CloudFormation Stack`);
} else {
throw new Error(`Nested Stack '${logicalId}' was not included in the nestedStacks property when including the parent template`);
}
}
return this.nestedStacks[logicalId];
}

/** @internal */
public _toCloudFormation(): object {
const ret: { [section: string]: any } = {};
Expand Down Expand Up @@ -283,7 +342,10 @@ export class CfnInclude extends core.CfnElement {
const options: core.FromCloudFormationOptions = {
finder,
};
const l1Instance = jsClassFromModule.fromCloudFormation(this, logicalId, resourceAttributes, options);

const l1Instance = this.nestedStacksToInclude[logicalId]
? this.createNestedStack(logicalId, finder)
: jsClassFromModule.fromCloudFormation(this, logicalId, resourceAttributes, options);

if (this.preserveLogicalIds) {
// override the logical ID to match the original template
Expand All @@ -293,4 +355,66 @@ export class CfnInclude extends core.CfnElement {
this.resources[logicalId] = l1Instance;
return l1Instance;
}

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

if (nestedStackAttributes.Type !== 'AWS::CloudFormation::Stack') {
throw new Error(`Nested Stack with logical ID '${nestedStackId}' is not an AWS::CloudFormation::Stack resource`);
}
if (nestedStackAttributes.CreationPolicy) {
throw new Error('CreationPolicy is not supported by the AWS::CloudFormation::Stack resource');
}
if (nestedStackAttributes.UpdatePolicy) {
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,
notificationArns: nestedStackProps.NotificationArns,
timeout: nestedStackProps.Timeout,
});

// 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;
}

const propStack = this.nestedStacksToInclude[nestedStackId];
const template = new CfnInclude(nestedStack, nestedStackId, {
templateFile: propStack.templateFile,
nestedStacks: propStack.nestedStacks,
});
const includedStack: IncludedNestedStack = { stack: nestedStack, includedTemplate: template };
this.nestedStacks[nestedStackId] = includedStack;

return nestedStackResource;
}
}
9 changes: 8 additions & 1 deletion packages/@aws-cdk/cloudformation-include/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"watch": "cdk-watch",
"lint": "cdk-lint",
"test": "cdk-test",
"integ": "cdk-integ",
"pkglint": "pkglint -f",
"package": "cdk-package",
"awslint": "cdk-awslint",
Expand Down Expand Up @@ -305,9 +306,10 @@
"@types/jest": "^26.0.4",
"@types/yaml": "1.2.0",
"cdk-build-tools": "0.0.0",
"cdk-integ-tools": "0.0.0",
"jest": "^25.4.0",
"pkglint": "0.0.0",
"ts-jest": "^26.1.2"
"ts-jest": "^26.1.1"
},
"bundledDependencies": [
"yaml"
Expand All @@ -327,6 +329,11 @@
"engines": {
"node": ">= 10.13.0 <13 || >=13.7.0"
},
"awslint": {
"exclude": [
"props-no-cfn-types:@aws-cdk/cloudformation-include.CfnIncludeProps.nestedStacks"
]
},
"stability": "experimental",
"maturity": "experimental",
"awscdkio": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"Resources": {
"ChildStack": {
"Type": "AWS::CloudFormation::Stack",
"Properties": {
"TemplateURL": {
"Fn::Join": [
"",
[
"https://s3.",
{
"Ref": "AWS::Region"
},
".",
{
"Ref": "AWS::URLSuffix"
},
"/",
{
"Ref": "AssetParameters5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50S3BucketEAA24F0C"
},
"/",
{
"Fn::Select": [
0,
{
"Fn::Split": [
"||",
{
"Ref": "AssetParameters5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50S3VersionKey1194CAB2"
}
]
}
]
},
{
"Fn::Select": [
1,
{
"Fn::Split": [
"||",
{
"Ref": "AssetParameters5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50S3VersionKey1194CAB2"
}
]
}
]
}
]
]
},
"Parameters": {
"MyBucketParameter": "some-magic-bucket-name"
}
}
}
},
"Parameters": {
"AssetParameters5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50S3BucketEAA24F0C": {
"Type": "String",
"Description": "S3 bucket for asset \"5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50\""
},
"AssetParameters5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50S3VersionKey1194CAB2": {
"Type": "String",
"Description": "S3 key for asset version \"5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50\""
},
"AssetParameters5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50ArtifactHash9C417847": {
"Type": "String",
"Description": "Artifact hash for asset \"5dc7d4a99cfe2979687dc74f2db9fd75f253b5505a1912b5ceecf70c9aefba50\""
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as core from '@aws-cdk/core';
import * as inc from '../lib';

const app = new core.App();

const stack = new core.Stack(app, 'ParentStack');

new inc.CfnInclude(stack, 'ParentStack', {
templateFile: 'test-templates/nested/parent-one-child.json',
nestedStacks: {
ChildStack: {
templateFile: 'test-templates/nested/grandchild-import-stack.json',
},
},
});

app.synth();
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as path from 'path';
import { SynthUtils } from '@aws-cdk/assert';
import '@aws-cdk/assert/jest';
import * as core from '@aws-cdk/core';
import * as path from 'path';
import * as inc from '../lib';

describe('CDK Include', () => {
Expand Down
Loading

0 comments on commit bf12456

Please sign in to comment.