Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): can use Constructs to model collections of Stacks #1940

Merged
merged 12 commits into from
Mar 20, 2019
2,783 changes: 1,804 additions & 979 deletions package-lock.json

Large diffs are not rendered by default.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@
{
"Ref": "AWS::Partition"
},
":cloudformation:us-west-2:12345678:stack/aws-cdk-codepipeline-cross-region-deploy-stack/*"
":cloudformation:us-west-2:",
{
"Ref": "AWS::AccountId"
},
":stack/aws-cdk-codepipeline-cross-region-deploy-stack/*"
]
]
}
Expand Down
33 changes: 31 additions & 2 deletions packages/@aws-cdk/cdk/lib/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@ import cxapi = require('@aws-cdk/cx-api');
import { ConstructOrder, Root } from './construct';
import { FileSystemStore, InMemoryStore, ISynthesisSession, SynthesisSession } from './synthesis';

/**
* Custom construction properties for a CDK program
*/
export interface AppProps {
/**
* Automatically call run before the application exits
*
* If you set this, you don't have to call `run()` anymore.
*
* @default true if running via CDK toolkit (CDK_OUTDIR is set), false otherwise
*/
autoRun?: boolean;

/**
* Additional context values for the application
*
* @default No additional context
*/
context?: { [key: string]: string };
}

/**
* Represents a CDK program.
*/
Expand All @@ -14,13 +35,21 @@ export class App extends Root {
* Initializes a CDK application.
* @param request Optional toolkit request (e.g. for tests)
*/
constructor(context?: { [key: string]: string }) {
constructor(props: AppProps = {}) {
super();
this.loadContext(context);
this.loadContext(props.context);

// both are reverse logic
this.legacyManifest = this.node.getContext(cxapi.DISABLE_LEGACY_MANIFEST_CONTEXT) ? false : true;
this.runtimeInformation = this.node.getContext(cxapi.DISABLE_VERSION_REPORTING) ? false : true;

const autoRun = props.autoRun !== undefined ? props.autoRun : cxapi.OUTDIR_ENV in process.env;

if (autoRun) {
// run() guarantuees it will only execute once, so a default of 'true' doesn't bite manual calling
// of the function.
process.once('beforeExit', () => this.run());
}
}

/**
Expand Down
16 changes: 4 additions & 12 deletions packages/@aws-cdk/cdk/lib/construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,12 @@ export class ConstructNode {
}

/**
* The full path of this construct in the tree.
* The full, absolute path of this construct in the tree.
*
* Components are separated by '/'.
*/
public get path(): string {
const components = this.rootPath().map(c => c.node.id);
const components = this.ancestors().slice(1).map(c => c.node.id);
return components.join(PATH_SEP);
}

Expand All @@ -119,7 +120,7 @@ export class ConstructNode {
* Includes all components of the tree.
*/
public get uniqueId(): string {
const components = this.rootPath().map(c => c.node.id);
const components = this.ancestors().slice(1).map(c => c.node.id);
return components.length > 0 ? makeUniqueId(components) : '';
}

Expand Down Expand Up @@ -547,15 +548,6 @@ export class ConstructNode {
}
}

/**
* Return the path of components up to but excluding the root
*/
private rootPath(): IConstruct[] {
const ancestors = this.ancestors();
ancestors.shift();
return ancestors;
}

/**
* If the construct ID contains a path separator, it is replaced by double dash (`--`).
*/
Expand Down
82 changes: 62 additions & 20 deletions packages/@aws-cdk/cdk/lib/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Environment } from './environment';
import { HashedAddressingScheme, IAddressingScheme, LogicalIDs } from './logical-id';
import { Reference } from './reference';
import { ISynthesisSession } from './synthesis';
import { makeUniqueId } from './uniqueid';

export interface StackProps {
/**
Expand All @@ -16,6 +17,13 @@ export interface StackProps {
*/
env?: Environment;

/**
* Name to deploy the stack with
*
* @default Derived from construct path
*/
stackName?: string;

/**
* Strategy for logical ID generation
*
Expand Down Expand Up @@ -76,6 +84,9 @@ export class Stack extends Construct {

/**
* The CloudFormation stack name.
*
* This is the stack name either configuration via the `stackName` property
* or automatically derived from the construct path.
*/
public readonly name: string;

Expand All @@ -94,25 +105,33 @@ export class Stack extends Construct {
*/
private readonly parameterValues: { [logicalId: string]: string } = { };

/**
* Environment as configured via props
*
* (Both on Stack and inherited from App)
*/
private readonly configuredEnv: Environment;

/**
* Creates a new stack.
*
* @param scope Parent of this stack, usually a Program instance.
* @param name The name of the CloudFormation stack. Defaults to "Stack".
* @param props Stack properties.
*/
public constructor(scope?: App, name?: string, private readonly props?: StackProps) {
public constructor(scope?: Construct, name?: string, props: StackProps = {}) {
// For unit test convenience parents are optional, so bypass the type check when calling the parent.
super(scope!, name!);

if (name && !Stack.VALID_STACK_NAME_REGEX.test(name)) {
throw new Error(`Stack name must match the regular expression: ${Stack.VALID_STACK_NAME_REGEX.toString()}, got '${name}'`);
}

this.env = this.parseEnvironment(props);
this.configuredEnv = props.env || {};
this.env = this.parseEnvironment(props.env);

this.logicalIds = new LogicalIDs(props && props.namingScheme ? props.namingScheme : new HashedAddressingScheme());
this.name = this.node.id;
this.name = props.stackName !== undefined ? props.stackName : this.calculateStackName();
}

/**
Expand Down Expand Up @@ -263,8 +282,8 @@ export class Stack extends Construct {
* to the correct account at deployment time.
*/
public get accountId(): string {
if (this.props && this.props.env && this.props.env.account) {
return this.props.env.account;
if (this.configuredEnv.account) {
return this.configuredEnv.account;
}
// Does not need to be scoped, the only situation in which
// Export/Fn::ImportValue would work if { Ref: "AWS::AccountId" } is the
Expand All @@ -280,8 +299,8 @@ export class Stack extends Construct {
* to the correct region at deployment time.
*/
public get region(): string {
if (this.props && this.props.env && this.props.env.region) {
return this.props.env.region;
if (this.configuredEnv.region) {
return this.configuredEnv.region;
}
// Does not need to be scoped, the only situation in which
// Export/Fn::ImportValue would work if { Ref: "AWS::AccountId" } is the
Expand Down Expand Up @@ -318,7 +337,7 @@ export class Stack extends Construct {
/**
* The name of the stack currently being deployed
*
* Only available at deployment time.
* Only available at deployment time; this will always return an unresolved value.
*/
public get stackName(): string {
return new ScopedAws(this).stackName;
Expand Down Expand Up @@ -445,7 +464,7 @@ export class Stack extends Construct {
}

protected synthesize(session: ISynthesisSession): void {
const template = `${this.node.id}.template.json`;
const template = `${this.name}.template.json`;

// write the CloudFormation template as a JSON file
session.store.writeJson(template, this._toCloudFormation());
Expand All @@ -463,7 +482,7 @@ export class Stack extends Construct {
artifact.properties.parameters = this.node.resolve(this.parameterValues);
}

const deps = this.dependencies().map(s => s.node.id);
const deps = this.dependencies().map(s => s.name);
if (deps.length > 0) {
artifact.dependencies = deps;
}
Expand All @@ -478,27 +497,26 @@ export class Stack extends Construct {
}

// add an artifact that represents this stack
session.addArtifact(this.node.id, artifact);
session.addArtifact(this.name, artifact);
}

/**
* Applied defaults to environment attributes.
*/
private parseEnvironment(props?: StackProps) {
// start with `env`.
const env: Environment = (props && props.env) || { };
private parseEnvironment(env: Environment = {}) {
const ret: Environment = {...env};

// if account is not specified, attempt to read from context.
if (!env.account) {
env.account = this.node.getContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY);
if (!ret.account) {
ret.account = this.node.getContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY);
}

// if region is not specified, attempt to read from context.
if (!env.region) {
env.region = this.node.getContext(cxapi.DEFAULT_REGION_CONTEXT_KEY);
if (!ret.region) {
ret.region = this.node.getContext(cxapi.DEFAULT_REGION_CONTEXT_KEY);
}

return env;
return ret;
}

/**
Expand Down Expand Up @@ -535,6 +553,27 @@ export class Stack extends Construct {
}
}
}

/**
* Calculcate the stack name based on the construct path
*/
private calculateStackName() {
// In tests, it's possible for this stack to be the root object, in which case
// we need to use it as part of the root path.
const rootPath = this.node.scope !== undefined ? this.node.ancestors().slice(1) : [this];
const ids = rootPath.map(c => c.node.id);

// Special case, if rootPath is length 1 then just use ID (backwards compatibility)
// otherwise use a unique stack name (including hash). This logic is already
// in makeUniqueId, *however* makeUniqueId will also strip dashes from the name,
// which *are* allowed and also used, so we short-circuit it.
if (ids.length === 1) {
// Could be empty in a unit test, so just pretend it's named "Stack" then
return ids[0] || 'Stack';
}

return makeUniqueId(ids);
}
}

function merge(template: any, part: any) {
Expand Down Expand Up @@ -584,7 +623,7 @@ export interface TemplateOptions {
}

/**
* Collect all CfnElements from a construct
* Collect all CfnElements from a Stack
*
* @param node Root node to collect all CfnElements from
* @param into Array to append CfnElements to
Expand All @@ -596,6 +635,9 @@ function cfnElements(node: IConstruct, into: CfnElement[] = []): CfnElement[] {
}

for (const child of node.node.children) {
// Don't recurse into a substack
if (Stack.isStack(child)) { continue; }

cfnElements(child, into);
}

Expand Down
27 changes: 26 additions & 1 deletion packages/@aws-cdk/cdk/test/test.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CfnResource, Construct, Stack, StackProps } from '../lib';
import { App } from '../lib/app';

function withApp(context: { [key: string]: any } | undefined, block: (app: App) => void): cxapi.SynthesizeResponse {
const app = new App(context);
const app = new App({ context });

block(app);

Expand Down Expand Up @@ -308,6 +308,31 @@ export = {

test.done();
},

'deep stack is shown and synthesized properly'(test: Test) {
// WHEN
const response = withApp(undefined, (app) => {
const topStack = new Stack(app, 'Stack');
const topResource = new CfnResource(topStack, 'Res', { type: 'CDK::TopStack::Resource' });

const bottomStack = new Stack(topResource, 'Stack');
new CfnResource(bottomStack, 'Res', { type: 'CDK::BottomStack::Resource' });
});

// THEN
test.deepEqual(response.stacks.map(s => ({ name: s.name, template: s.template })), [
{
name: 'StackResStack7E4AFA86',
template: { Resources: { Res: { Type: 'CDK::BottomStack::Resource' } } },
},
{
name: 'Stack',
template: { Resources: { Res: { Type: 'CDK::TopStack::Resource' } } },
}
]);

test.done();
},
};

class MyConstruct extends Construct {
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/cdk/test/test.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export = {
test.deepEqual(new AvailabilityZoneProvider(stack).availabilityZones, [ 'dummy1a', 'dummy1b', 'dummy1c' ]);
test.deepEqual(new SSMParameterProvider(child, {parameterName: 'foo'}).parameterValue(), 'dummy');

const output = app.synthesizeStack(stack.node.id);
const output = app.synthesizeStack(stack.name);

const azError: MetadataEntry | undefined = output.metadata['/test-stack'].find(x => x.type === cxapi.ERROR_METADATA_KEY);
const ssmError: MetadataEntry | undefined = output.metadata['/test-stack/ChildConstruct'].find(x => x.type === cxapi.ERROR_METADATA_KEY);
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/cdk/test/test.output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export = {
'if stack name is undefined, we will only use the logical ID for the export name'(test: Test) {
const stack = new Stack();
const output = new CfnOutput(stack, 'MyOutput');
test.deepEqual(stack.node.resolve(output.makeImportValue()), { 'Fn::ImportValue': 'MyOutput' });
test.deepEqual(stack.node.resolve(output.makeImportValue()), { 'Fn::ImportValue': 'Stack:MyOutput' });
test.done();
},

Expand Down
24 changes: 23 additions & 1 deletion packages/@aws-cdk/cdk/test/test.stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,29 @@ export = {
{ RefToBonjour: { Ref: 'BOOM' },
GetAttBonjour: { 'Fn::GetAtt': [ 'BOOM', 'TheAtt' ] } } } } });
test.done();
}
},

'Stack name can be overridden via properties'(test: Test) {
// WHEN
const stack = new Stack(undefined, 'Stack', { stackName: 'otherName' });

// THEN
test.deepEqual(stack.name, 'otherName');

test.done();
},

'Stack name is inherited from App name if available'(test: Test) {
// WHEN
const root = new App();
const app = new Construct(root, 'Prod');
const stack = new Stack(app, 'Stack');

// THEN
test.deepEqual(stack.name, 'ProdStackD5279B22');

test.done();
},
};

class StackWithPostProcessor extends Stack {
Expand Down
Loading