diff --git a/packages/@aws-cdk/assert/lib/inspector.ts b/packages/@aws-cdk/assert/lib/inspector.ts index 9065e79e0413e..a59dd645376d6 100644 --- a/packages/@aws-cdk/assert/lib/inspector.ts +++ b/packages/@aws-cdk/assert/lib/inspector.ts @@ -63,7 +63,7 @@ export class StackPathInspector extends Inspector { // then try with the stack name preprended for backwards compat with most tests that happen to give // their stack an ID that's the same as the stack name. const metadata = this.stack.manifest.metadata || {}; - const md = metadata[this.path] || metadata[`/${this.stack.name}${this.path}`]; + const md = metadata[this.path] || metadata[`/${this.stack.id}${this.path}`]; if (md === undefined) { return undefined; } const resourceMd = md.find(entry => entry.type === api.LOGICAL_ID_METADATA_KEY); if (resourceMd === undefined) { return undefined; } diff --git a/packages/@aws-cdk/assert/lib/synth-utils.ts b/packages/@aws-cdk/assert/lib/synth-utils.ts index 330a7936874fd..7d9c142cb37e9 100644 --- a/packages/@aws-cdk/assert/lib/synth-utils.ts +++ b/packages/@aws-cdk/assert/lib/synth-utils.ts @@ -11,7 +11,7 @@ export class SynthUtils { // if the root is an app, invoke "synth" to avoid double synthesis const assembly = root instanceof App ? root.synth() : ConstructNode.synth(root.node, options); - return assembly.getStack(stack.stackName); + return assembly.getStackArtifact(stack.artifactId); } /** @@ -61,7 +61,7 @@ export class SynthUtils { return JSON.parse(fs.readFileSync(path.join(assembly.directory, stack.templateFile)).toString('utf-8')); } - return assembly.getStack(stack.stackName); + return assembly.getStackArtifact(stack.artifactId); } } diff --git a/packages/@aws-cdk/assert/test/test.assertions.ts b/packages/@aws-cdk/assert/test/test.assertions.ts index 329520fe68c4c..ea0c53a209ed1 100644 --- a/packages/@aws-cdk/assert/test/test.assertions.ts +++ b/packages/@aws-cdk/assert/test/test.assertions.ts @@ -265,7 +265,7 @@ function synthesizedStack(fn: (stack: cdk.Stack) => void): cx.CloudFormationStac fn(stack); const assembly = app.synth(); - return assembly.getStack(stack.stackName); + return assembly.getStackArtifact(stack.artifactId); } interface TestResourceProps extends cdk.CfnResourceProps { diff --git a/packages/@aws-cdk/assert/test/test.have-resource.ts b/packages/@aws-cdk/assert/test/test.have-resource.ts index 2aafbacd40855..65b119fd74605 100644 --- a/packages/@aws-cdk/assert/test/test.have-resource.ts +++ b/packages/@aws-cdk/assert/test/test.have-resource.ts @@ -103,5 +103,5 @@ function mkStack(template: any): cxapi.CloudFormationStackArtifact { }); writeFileSync(join(assembly.outdir, 'template.json'), JSON.stringify(template)); - return assembly.buildAssembly().getStack('test'); + return assembly.buildAssembly().getStackByName('test'); } diff --git a/packages/@aws-cdk/aws-cloudformation/test/test.nested-stack.ts b/packages/@aws-cdk/aws-cloudformation/test/test.nested-stack.ts index 41b9a1cf8753d..3830081c8ff6b 100644 --- a/packages/@aws-cdk/aws-cloudformation/test/test.nested-stack.ts +++ b/packages/@aws-cdk/aws-cloudformation/test/test.nested-stack.ts @@ -84,7 +84,7 @@ export = { const assembly = app.synth(); // THEN - test.deepEqual(assembly.getStack(parent.stackName).assets, [{ + test.deepEqual(assembly.getStackByName(parent.stackName).assets, [{ path: 'parentstacknestedstack844892C0.nested.template.json', id: 'c639c0a5e7320758aa22589669ecebc98f185b711300b074f53998c8f9a45096', packaging: 'file', @@ -418,8 +418,8 @@ export = { }); // verify a depedency was established between the parents - const stack1Artifact = assembly.getStack(stack1.stackName); - const stack2Artifact = assembly.getStack(stack2.stackName); + const stack1Artifact = assembly.getStackByName(stack1.stackName); + const stack2Artifact = assembly.getStackByName(stack2.stackName); test.deepEqual(stack1Artifact.dependencies.length, 1); test.deepEqual(stack2Artifact.dependencies.length, 0); test.same(stack1Artifact.dependencies[0], stack2Artifact); @@ -465,7 +465,7 @@ export = { }); // parent stack (stack1) should export this value - test.deepEqual(assembly.getStack(stack1.stackName).template.Outputs, { + test.deepEqual(assembly.getStackByName(stack1.stackName).template.Outputs, { ExportsOutputFnGetAttNestedUnderStack1NestedStackNestedUnderStack1NestedStackResourceF616305BOutputsStack1NestedUnderStack1ResourceInNestedStack6EE9DCD2MyAttribute564EECF3: { Value: { 'Fn::GetAtt': ['NestedUnderStack1NestedStackNestedUnderStack1NestedStackResourceF616305B', 'Outputs.Stack1NestedUnderStack1ResourceInNestedStack6EE9DCD2MyAttribute'] }, Export: { Name: 'Stack1:ExportsOutputFnGetAttNestedUnderStack1NestedStackNestedUnderStack1NestedStackResourceF616305BOutputsStack1NestedUnderStack1ResourceInNestedStack6EE9DCD2MyAttribute564EECF3' } @@ -487,8 +487,8 @@ export = { }); test.deepEqual(assembly.stacks.length, 2); - const stack1Artifact = assembly.getStack(stack1.stackName); - const stack2Artifact = assembly.getStack(stack2.stackName); + const stack1Artifact = assembly.getStackByName(stack1.stackName); + const stack2Artifact = assembly.getStackByName(stack2.stackName); test.deepEqual(stack1Artifact.dependencies.length, 0); test.deepEqual(stack2Artifact.dependencies.length, 1); test.same(stack2Artifact.dependencies[0], stack1Artifact); @@ -718,7 +718,7 @@ export = { })); // parent stack should have 2 assets - test.deepEqual(assembly.getStack(parent.stackName).assets.length, 2); + test.deepEqual(assembly.getStackByName(parent.stackName).assets.length, 2); test.done(); }, @@ -764,7 +764,7 @@ export = { })); // parent stack should have 2 assets - test.deepEqual(assembly.getStack(parent.stackName).assets.length, 2); + test.deepEqual(assembly.getStackByName(parent.stackName).assets.length, 2); test.done(); }, @@ -819,8 +819,8 @@ export = { // THEN: the first non-nested stack records the assembly metadata const asm = app.synth(); test.deepEqual(asm.stacks.length, 2); // only one stack is defined as an artifact - test.deepEqual(asm.getStack(parent.stackName).findMetadataByType('foo'), []); - test.deepEqual(asm.getStack(child.stackName).findMetadataByType('foo'), [ + test.deepEqual(asm.getStackByName(parent.stackName).findMetadataByType('foo'), []); + test.deepEqual(asm.getStackByName(child.stackName).findMetadataByType('foo'), [ { path: '/parent/child/nested/resource', type: 'foo', diff --git a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts index 80a7e030e2609..999eedde7f0d7 100644 --- a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts +++ b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts @@ -655,7 +655,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; expect(stack).to(haveResource("AWS::AutoScaling::LaunchConfiguration", { ImageId: { Ref: "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2gpurecommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter" @@ -709,7 +709,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.deepEqual(template.Parameters, { SsmParameterValueawsserviceecsoptimizedamiwindowsserver2019englishfullrecommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter: { Type: "AWS::SSM::Parameter::Value", @@ -846,7 +846,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; expect(stack).to(haveResource("AWS::AutoScaling::LaunchConfiguration", { ImageId: { Ref: "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2gpurecommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter" @@ -877,7 +877,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; expect(stack).to(haveResource("AWS::AutoScaling::LaunchConfiguration", { ImageId: { Ref: "SsmParameterValueawsserviceecsoptimizedamiamazonlinuxrecommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter" @@ -908,7 +908,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.deepEqual(template.Parameters, { SsmParameterValueawsserviceecsoptimizedamiwindowsserver2019englishfullrecommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter: { Type: "AWS::SSM::Parameter::Value", diff --git a/packages/@aws-cdk/aws-eks/test/test.cluster.ts b/packages/@aws-cdk/aws-eks/test/test.cluster.ts index 1d1ba13abb281..66bc257bfaf94 100644 --- a/packages/@aws-cdk/aws-eks/test/test.cluster.ts +++ b/packages/@aws-cdk/aws-eks/test/test.cluster.ts @@ -311,7 +311,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.deepEqual(template.Outputs, { ClusterConfigCommand43AAE40F: { Value: { 'Fn::Join': [ '', [ 'aws eks update-kubeconfig --name ', { Ref: 'Cluster9EE0221C' }, ' --region us-east-1' ] ] } }, ClusterGetTokenCommand06AE992E: { Value: { 'Fn::Join': [ '', [ 'aws eks get-token --cluster-name ', { Ref: 'Cluster9EE0221C' }, ' --region us-east-1' ] ] } } @@ -329,7 +329,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.deepEqual(template.Outputs, { ClusterConfigCommand43AAE40F: { Value: { 'Fn::Join': [ '', [ 'aws eks update-kubeconfig --name ', { Ref: 'Cluster9EE0221C' }, ' --region us-east-1 --role-arn ', { 'Fn::GetAtt': [ 'masters0D04F23D', 'Arn' ] } ] ] } }, ClusterGetTokenCommand06AE992E: { Value: { 'Fn::Join': [ '', [ 'aws eks get-token --cluster-name ', { Ref: 'Cluster9EE0221C' }, ' --region us-east-1 --role-arn ', { 'Fn::GetAtt': [ 'masters0D04F23D', 'Arn' ] } ] ] } } @@ -350,7 +350,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.ok(!template.Outputs); // no outputs test.done(); }, @@ -367,7 +367,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.deepEqual(template.Outputs, { ClusterClusterNameEB26049E: { Value: { Ref: 'Cluster9EE0221C' } } }); @@ -387,7 +387,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.deepEqual(template.Outputs, { ClusterMastersRoleArnB15964B1: { Value: { 'Fn::GetAtt': [ 'masters0D04F23D', 'Arn' ] } } }); @@ -406,7 +406,7 @@ export = { // THEN const assembly = app.synth(); - const template = assembly.getStack(stack.stackName).template; + const template = assembly.getStackByName(stack.stackName).template; test.deepEqual(template.Outputs, { ClusterDefaultCapacityInstanceRoleARN7DADF219: { Value: { 'Fn::GetAtt': [ 'ClusterDefaultCapacityInstanceRole3E209969', 'Arn' ] } @@ -427,7 +427,7 @@ export = { cluster.addCapacity('MyCapcity', { instanceType: new ec2.InstanceType('m3.xlargs') }); // THEN - const template = app.synth().getStack(stack.stackName).template; + const template = app.synth().getStackByName(stack.stackName).template; const userData = template.Resources.ClusterMyCapcityLaunchConfig58583345.Properties.UserData; test.deepEqual(userData, { 'Fn::Base64': { 'Fn::Join': [ '', [ '#!/bin/bash\nset -o xtrace\n/etc/eks/bootstrap.sh ', { Ref: 'Cluster9EE0221C' }, ' --kubelet-extra-args "--node-labels lifecycle=OnDemand" --use-max-pods true\n/opt/aws/bin/cfn-signal --exit-code $? --stack Stack --resource ClusterMyCapcityASGD4CD8B97 --region us-east-1' ] ] } }); test.done(); @@ -445,7 +445,7 @@ export = { }); // THEN - const template = app.synth().getStack(stack.stackName).template; + const template = app.synth().getStackByName(stack.stackName).template; const userData = template.Resources.ClusterMyCapcityLaunchConfig58583345.Properties.UserData; test.deepEqual(userData, { "Fn::Base64": "#!/bin/bash" }); test.done(); @@ -466,7 +466,7 @@ export = { }); // THEN - const template = app.synth().getStack(stack.stackName).template; + const template = app.synth().getStackByName(stack.stackName).template; const userData = template.Resources.ClusterMyCapcityLaunchConfig58583345.Properties.UserData; test.deepEqual(userData, { 'Fn::Base64': { 'Fn::Join': [ '', [ '#!/bin/bash\nset -o xtrace\n/etc/eks/bootstrap.sh ', { Ref: 'Cluster9EE0221C' }, ' --kubelet-extra-args "--node-labels lifecycle=OnDemand --node-labels FOO=42" --use-max-pods true\n/opt/aws/bin/cfn-signal --exit-code $? --stack Stack --resource ClusterMyCapcityASGD4CD8B97 --region us-east-1' ] ] } }); test.done(); @@ -486,7 +486,7 @@ export = { }); // THEN - const template = app.synth().getStack(stack.stackName).template; + const template = app.synth().getStackByName(stack.stackName).template; const userData = template.Resources.ClusterMyCapcityLaunchConfig58583345.Properties.UserData; test.deepEqual(userData, { 'Fn::Base64': { 'Fn::Join': [ '', [ '#!/bin/bash\nset -o xtrace\n/etc/eks/bootstrap.sh ', { Ref: 'Cluster9EE0221C' }, ' --kubelet-extra-args "--node-labels lifecycle=Ec2Spot --register-with-taints=spotInstance=true:PreferNoSchedule" --use-max-pods true\n/opt/aws/bin/cfn-signal --exit-code $? --stack Stack --resource ClusterMyCapcityASGD4CD8B97 --region us-east-1' ] ] } }); test.done(); diff --git a/packages/@aws-cdk/aws-s3-assets/test/test.asset.ts b/packages/@aws-cdk/aws-s3-assets/test/test.asset.ts index 9d5832ee54ee6..86db11ea34414 100644 --- a/packages/@aws-cdk/aws-s3-assets/test/test.asset.ts +++ b/packages/@aws-cdk/aws-s3-assets/test/test.asset.ts @@ -60,7 +60,7 @@ export = { path: dirPath }); - const synth = app.synth().getStack(stack.stackName); + const synth = app.synth().getStackByName(stack.stackName); const meta = synth.manifest.metadata || {}; test.ok(meta['/my-stack']); test.ok(meta['/my-stack'][0]); @@ -340,7 +340,7 @@ export = { // WHEN const session = app.synth(); - const artifact = session.getStack(stack.stackName); + const artifact = session.getStackByName(stack.stackName); const metadata = artifact.manifest.metadata || {}; const md = Object.values(metadata)[0]![0]!.data; test.deepEqual(md.path, 'asset.6b84b87243a4a01c592d78e1fd3855c4bfef39328cd0a450cc97e81717fea2a2'); diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index e469aba35947b..dd72cdd1f0193 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -172,6 +172,11 @@ export class Stack extends Construct implements ITaggable { */ public readonly templateFile: string; + /** + * The ID of the cloud assembly artifact for this stack. + */ + public readonly artifactId: string; + /** * Logical ID generation strategy */ @@ -201,12 +206,14 @@ export class Stack extends Construct implements ITaggable { * 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 id The construct ID of this stack. If `stackName` is not explicitly + * defined, this id (and any parent IDs) will be used to determine the + * physical ID of the stack. * @param props Stack properties. */ - public constructor(scope?: Construct, name?: string, props: StackProps = {}) { + public constructor(scope?: Construct, id?: string, props: StackProps = {}) { // For unit test convenience parents are optional, so bypass the type check when calling the parent. - super(scope!, name!); + super(scope!, id!); Object.defineProperty(this, STACK_SYMBOL, { value: true }); @@ -227,14 +234,26 @@ export class Stack extends Construct implements ITaggable { this.templateOptions.description = props.description; } - this._stackName = props.stackName !== undefined ? props.stackName : this.calculateStackName(); + this._stackName = props.stackName !== undefined ? props.stackName : this.generateUniqueStackName(); this.tags = new TagManager(TagType.KEY_VALUE, 'aws:cdk:stack', props.tags); if (!VALID_STACK_NAME_REGEX.test(this.stackName)) { - throw new Error(`Stack name must match the regular expression: ${VALID_STACK_NAME_REGEX.toString()}, got '${name}'`); + throw new Error(`Stack name must match the regular expression: ${VALID_STACK_NAME_REGEX.toString()}, got '${id}'`); } - this.templateFile = `${this.stackName}.template.json`; + // we use `generateUniqueStackName` here as the artifact ID. This will + // ensure that in case where `stackName` is not explicitly configured, + // artifact ID and stack name will be the same and therefore the template + // file name will be the same as `.template.json` (for backwards + // compatibility with the behavior before we + // ENABLE_STACK_NAME_DUPLICATES_CONTEXT was introduced). + this.artifactId = this.generateUniqueStackName(); + + const templateFileName = this.node.tryGetContext(cxapi.ENABLE_STACK_NAME_DUPLICATES_CONTEXT) + ? this.artifactId + : this.stackName; + + this.templateFile = `${templateFileName}.template.json`; this.templateUrl = Lazy.stringValue({ produce: () => this._templateUrl || '' }); } @@ -704,15 +723,27 @@ export class Stack extends Construct implements ITaggable { return; } - const deps = this.dependencies.map(s => s.stackName); + const deps = this.dependencies.map(s => s.artifactId); const meta = this.collectMetadata(); + // backwards compatibility since originally artifact ID was always equal to + // stack name the stackName attribute is optional and if it is not specified + // the CLI will use the artifact ID as the stack name. we *could have* + // always put the stack name here but wanted to minimize the risk around + // changes to the assembly manifest. so this means that as long as stack + // name and artifact ID are the same, the cloud assembly manifest will not + // change. + const stackNameProperty = this.stackName === this.artifactId + ? { } + : { stackName: this.stackName }; + const properties: cxapi.AwsCloudFormationStackProperties = { - templateFile: this.templateFile + templateFile: this.templateFile, + ...stackNameProperty }; // add an artifact that represents this stack - builder.addArtifact(this.stackName, { + builder.addArtifact(this.artifactId, { type: cxapi.ArtifactType.AWS_CLOUDFORMATION_STACK, environment: this.environment, properties, @@ -892,7 +923,7 @@ export class Stack extends Construct implements ITaggable { /** * Calculcate the stack name based on the construct path */ - private calculateStackName() { + private generateUniqueStackName() { // 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.scopes.slice(1) : [this]; diff --git a/packages/@aws-cdk/core/test/test.app.ts b/packages/@aws-cdk/core/test/test.app.ts index 6cc8914794243..c378920bde60a 100644 --- a/packages/@aws-cdk/core/test/test.app.ts +++ b/packages/@aws-cdk/core/test/test.app.ts @@ -36,7 +36,7 @@ function synth(context?: { [key: string]: any }): cxapi.CloudAssembly { function synthStack(name: string, includeMetadata: boolean = false, context?: any): cxapi.CloudFormationStackArtifact { const response = synth(context); - const stack = response.getStack(name); + const stack = response.getStackByName(name); if (!includeMetadata) { delete (stack as any).metadata; @@ -53,7 +53,8 @@ export = { test.deepEqual(response.stacks.length, 2); const stack1 = response.stacks[0]; - test.deepEqual(stack1.name, 'stack1'); + test.deepEqual(stack1.stackName, 'stack1'); + test.deepEqual(stack1.id, 'stack1'); test.deepEqual(stack1.environment.account, 12345); test.deepEqual(stack1.environment.region, 'us-east-1'); test.deepEqual(stack1.environment.name, 'aws://12345/us-east-1'); @@ -70,7 +71,8 @@ export = { }); const stack2 = response.stacks[1]; - test.deepEqual(stack2.name, 'stack2'); + test.deepEqual(stack2.stackName, 'stack2'); + test.deepEqual(stack2.id, 'stack2'); test.deepEqual(stack2.environment.name, 'aws://unknown-account/unknown-region'); test.deepEqual(stack2.template, { Resources: { s2c1: { Type: 'DummyResource', Properties: { Prog2: 'Prog2' } }, @@ -307,7 +309,7 @@ export = { }); // THEN - test.deepEqual(response.stacks.map(s => ({ name: s.name, template: s.template })), [ + test.deepEqual(response.stacks.map(s => ({ name: s.stackName, template: s.template })), [ { name: 'Stack', template: { Resources: { Res: { Type: 'CDK::TopStack::Resource' } } }, diff --git a/packages/@aws-cdk/core/test/test.cfn-resource.ts b/packages/@aws-cdk/core/test/test.cfn-resource.ts index 02bd72b82c2bb..b7aacfc40b478 100644 --- a/packages/@aws-cdk/core/test/test.cfn-resource.ts +++ b/packages/@aws-cdk/core/test/test.cfn-resource.ts @@ -14,7 +14,7 @@ export = nodeunit.testCase({ test.notEqual(val, null); }; - test.deepEqual(app.synth().getStack(stack.stackName).template, { + test.deepEqual(app.synth().getStackByName(stack.stackName).template, { Resources: { DefaultResource: { Type: 'Test::Resource::Fake' @@ -37,7 +37,7 @@ export = nodeunit.testCase({ resource.applyRemovalPolicy(core.RemovalPolicy.RETAIN); // THEN - test.deepEqual(app.synth().getStack(stack.stackName).template, { + test.deepEqual(app.synth().getStackByName(stack.stackName).template, { Resources: { DefaultResource: { Type: 'Test::Resource::Fake', @@ -62,7 +62,7 @@ export = nodeunit.testCase({ }); // THEN - test.deepEqual(app.synth().getStack(stack.stackName).template, { + test.deepEqual(app.synth().getStackByName(stack.stackName).template, { Resources: { DefaultResource: { Type: 'Test::Resource::Fake', diff --git a/packages/@aws-cdk/core/test/test.cloudformation-json.ts b/packages/@aws-cdk/core/test/test.cloudformation-json.ts index 38d5ae3dcc1cc..b09cb49a90b9a 100644 --- a/packages/@aws-cdk/core/test/test.cloudformation-json.ts +++ b/packages/@aws-cdk/core/test/test.cloudformation-json.ts @@ -215,7 +215,7 @@ export = { // THEN const asm = app.synth(); - test.deepEqual(asm.getStack('Stack2').template, { + test.deepEqual(asm.getStackByName('Stack2').template, { Outputs: { Stack1Id: { Value: { diff --git a/packages/@aws-cdk/core/test/test.cross-environment-token.ts b/packages/@aws-cdk/core/test/test.cross-environment-token.ts index c18ea934e382f..8126e34899dee 100644 --- a/packages/@aws-cdk/core/test/test.cross-environment-token.ts +++ b/packages/@aws-cdk/core/test/test.cross-environment-token.ts @@ -218,7 +218,7 @@ export = { const assembly = app.synth(); - test.deepEqual(assembly.getStack(parentStack.stackName).template, { + test.deepEqual(assembly.getStackByName(parentStack.stackName).template, { Resources: { ParentResource: { Type: 'Parent::Resource', @@ -229,7 +229,7 @@ export = { } }); - test.deepEqual(assembly.getStack(childStack.stackName).template, { + test.deepEqual(assembly.getStackByName(childStack.stackName).template, { Resources: { ChildResource8C37244D: { Type: 'My::Resource', diff --git a/packages/@aws-cdk/core/test/test.environment.ts b/packages/@aws-cdk/core/test/test.environment.ts index dee43ed915349..d31d51d237842 100644 --- a/packages/@aws-cdk/core/test/test.environment.ts +++ b/packages/@aws-cdk/core/test/test.environment.ts @@ -37,7 +37,7 @@ export = { // THEN test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); - test.deepEqual(app.synth().getStack(stack.stackName).environment, { + test.deepEqual(app.synth().getStackByName(stack.stackName).environment, { account: 'unknown-account', region: 'unknown-region', name: 'aws://unknown-account/unknown-region' @@ -56,7 +56,7 @@ export = { // THEN test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); test.deepEqual(stack.resolve(stack.region), 'explicit-region'); - test.deepEqual(app.synth().getStack(stack.stackName).environment, { + test.deepEqual(app.synth().getStackByName(stack.stackName).environment, { account: 'unknown-account', region: 'explicit-region', name: 'aws://unknown-account/explicit-region' @@ -78,7 +78,7 @@ export = { // THEN test.deepEqual(stack.resolve(stack.account), 'explicit-account'); test.deepEqual(stack.resolve(stack.region), 'explicit-region'); - test.deepEqual(app.synth().getStack(stack.stackName).environment, { + test.deepEqual(app.synth().getStackByName(stack.stackName).environment, { account: 'explicit-account', region: 'explicit-region', name: 'aws://explicit-account/explicit-region' @@ -102,7 +102,7 @@ export = { // THEN test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); - test.deepEqual(app.synth().getStack(stack.stackName).environment, { + test.deepEqual(app.synth().getStackByName(stack.stackName).environment, { account: 'unknown-account', region: 'unknown-region', name: 'aws://unknown-account/unknown-region' @@ -126,7 +126,7 @@ export = { // THEN test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); test.deepEqual(stack.resolve(stack.region), 'us-east-2'); - test.deepEqual(app.synth().getStack(stack.stackName).environment, { + test.deepEqual(app.synth().getStackByName(stack.stackName).environment, { account: 'unknown-account', region: 'us-east-2', name: 'aws://unknown-account/us-east-2' diff --git a/packages/@aws-cdk/core/test/test.fn.ts b/packages/@aws-cdk/core/test/test.fn.ts index 473b062537078..d1d67531fa3f7 100644 --- a/packages/@aws-cdk/core/test/test.fn.ts +++ b/packages/@aws-cdk/core/test/test.fn.ts @@ -152,7 +152,7 @@ export = nodeunit.testCase({ }); // THEN - const template = app.synth().getStack('Stack2').template; + const template = app.synth().getStackByName('Stack2').template; test.deepEqual(template, { Outputs: { diff --git a/packages/@aws-cdk/core/test/test.resource.ts b/packages/@aws-cdk/core/test/test.resource.ts index ce85b6b86c61a..5d33b5814cdfc 100644 --- a/packages/@aws-cdk/core/test/test.resource.ts +++ b/packages/@aws-cdk/core/test/test.resource.ts @@ -668,7 +668,7 @@ export = { // THEN const assembly = app.synth(); - const templateB = assembly.getStack(stackB.stackName).template; + const templateB = assembly.getStackByName(stackB.stackName).template; test.deepEqual(templateB, { Resources: { diff --git a/packages/@aws-cdk/core/test/test.stack.ts b/packages/@aws-cdk/core/test/test.stack.ts index b8efcc2c72c54..41d2cb9a9e3e9 100644 --- a/packages/@aws-cdk/core/test/test.stack.ts +++ b/packages/@aws-cdk/core/test/test.stack.ts @@ -167,8 +167,8 @@ export = { // THEN const assembly = app.synth(); - const template1 = assembly.getStack(stack1.stackName).template; - const template2 = assembly.getStack(stack2.stackName).template; + const template1 = assembly.getStackByName(stack1.stackName).template; + const template2 = assembly.getStackByName(stack2.stackName).template; test.deepEqual(template1, { Outputs: { @@ -205,7 +205,7 @@ export = { // THEN const assembly = app.synth(); - const template2 = assembly.getStack(stack2.stackName).template; + const template2 = assembly.getStackByName(stack2.stackName).template; test.deepEqual(template2, { Resources: { @@ -231,8 +231,8 @@ export = { new CfnParameter(stack2, 'SomeParameter', { type: 'String', default: Lazy.stringValue({ produce: () => account1 }) }); const assembly = app.synth(); - const template1 = assembly.getStack(stack1.stackName).template; - const template2 = assembly.getStack(stack2.stackName).template; + const template1 = assembly.getStackByName(stack1.stackName).template; + const template2 = assembly.getStackByName(stack2.stackName).template; // THEN test.deepEqual(template1, { @@ -268,7 +268,7 @@ export = { // THEN const assembly = app.synth(); - const template2 = assembly.getStack(stack2.stackName).template; + const template2 = assembly.getStackByName(stack2.stackName).template; test.deepEqual(template2, { Outputs: { @@ -295,7 +295,7 @@ export = { new CfnParameter(stack2, 'SomeParameter', { type: 'String', default: `TheAccountIs${account1}` }); const assembly = app.synth(); - const template2 = assembly.getStack(stack2.stackName).template; + const template2 = assembly.getStackByName(stack2.stackName).template; // THEN test.deepEqual(template2, { @@ -345,8 +345,8 @@ export = { // THEN const assembly = app.synth(); - test.deepEqual(assembly.getStack(parentStack.stackName).template, { Resources: { MyParentResource: { Type: 'Resource::Parent' } } }); - test.deepEqual(assembly.getStack(childStack.stackName).template, { Resources: { MyChildResource: { Type: 'Resource::Child' } } }); + test.deepEqual(assembly.getStackByName(parentStack.stackName).template, { Resources: { MyParentResource: { Type: 'Resource::Parent' } } }); + test.deepEqual(assembly.getStackByName(childStack.stackName).template, { Resources: { MyChildResource: { Type: 'Resource::Child' } } }); test.done(); }, @@ -367,14 +367,14 @@ export = { // THEN const assembly = app.synth(); - test.deepEqual(assembly.getStack(parentStack.stackName).template, { + test.deepEqual(assembly.getStackByName(parentStack.stackName).template, { Resources: { MyParentResource: { Type: 'Resource::Parent' } }, Outputs: { ExportsOutputFnGetAttMyParentResourceAttOfParentResourceC2D0BB9E: { Value: { 'Fn::GetAtt': [ 'MyParentResource', 'AttOfParentResource' ] }, Export: { Name: 'parent:ExportsOutputFnGetAttMyParentResourceAttOfParentResourceC2D0BB9E' } } } }); - test.deepEqual(assembly.getStack(childStack.stackName).template, { + test.deepEqual(assembly.getStackByName(childStack.stackName).template, { Resources: { MyChildResource: { Type: 'Resource::Child', @@ -406,7 +406,7 @@ export = { // THEN const assembly = app.synth(); - test.deepEqual(assembly.getStack(parentStack.stackName).template, { + test.deepEqual(assembly.getStackByName(parentStack.stackName).template, { Resources: { MyParentResource: { Type: 'Resource::Parent', @@ -417,7 +417,7 @@ export = { } }); - test.deepEqual(assembly.getStack(childStack.stackName).template, { + test.deepEqual(assembly.getStackByName(childStack.stackName).template, { Resources: { MyChildResource: { Type: 'Resource::Child' } }, Outputs: { @@ -573,7 +573,7 @@ export = { // THEN const session = app.synth(); test.deepEqual(stack.stackName, 'valid-stack-name'); - test.ok(session.tryGetArtifact('valid-stack-name')); + test.ok(session.tryGetArtifact(stack.artifactId)); test.done(); }, @@ -640,7 +640,7 @@ export = { test.done(); }, - 'stack.templateFile contains the name of the cloudformation output'(test: Test) { + 'stack.templateFile is the name of the template file emitted to the cloud assembly (default is to use the stack name)'(test: Test) { // GIVEN const app = new App(); @@ -654,6 +654,41 @@ export = { test.done(); }, + 'when feature flag is enabled we will use the artifact id as the template name'(test: Test) { + // GIVEN + const app = new App({ + context: { + [cxapi.ENABLE_STACK_NAME_DUPLICATES_CONTEXT]: 'true' + } + }); + + // WHEN + const stack1 = new Stack(app, 'MyStack1'); + const stack2 = new Stack(app, 'MyStack2', { stackName: 'MyRealStack2' }); + + // THEN + test.deepEqual(stack1.templateFile, 'MyStack1.template.json'); + test.deepEqual(stack2.templateFile, 'MyStack2.template.json'); + test.done(); + }, + + 'allow using the same stack name for two stacks (i.e. in different regions)'(test: Test) { + // GIVEN + const app = new App({ context: cxapi.FUTURE_FLAGS }); + + // WHEN + const stack1 = new Stack(app, 'MyStack1', { stackName: 'thestack' }); + const stack2 = new Stack(app, 'MyStack2', { stackName: 'thestack' }); + const assembly = app.synth(); + + // THEN + test.deepEqual(assembly.getStackArtifact(stack1.artifactId).templateFile, 'MyStack1.template.json'); + test.deepEqual(assembly.getStackArtifact(stack2.artifactId).templateFile, 'MyStack2.template.json'); + test.deepEqual(stack1.templateFile, 'MyStack1.template.json'); + test.deepEqual(stack2.templateFile, 'MyStack2.template.json'); + test.done(); + }, + 'metadata is collected at the stack boundary'(test: Test) { // GIVEN const app = new App({ @@ -669,8 +704,8 @@ export = { // THEN const asm = app.synth(); - test.deepEqual(asm.getStack(parent.stackName).findMetadataByType('foo'), []); - test.deepEqual(asm.getStack(child.stackName).findMetadataByType('foo'), [ + test.deepEqual(asm.getStackByName(parent.stackName).findMetadataByType('foo'), []); + test.deepEqual(asm.getStackByName(child.stackName).findMetadataByType('foo'), [ { path: '/parent/child', type: 'foo', data: 'bar' } ]); test.done(); diff --git a/packages/@aws-cdk/core/test/test.synthesis.ts b/packages/@aws-cdk/core/test/test.synthesis.ts index 5780f31c83fed..edaacab77db87 100644 --- a/packages/@aws-cdk/core/test/test.synthesis.ts +++ b/packages/@aws-cdk/core/test/test.synthesis.ts @@ -156,7 +156,7 @@ export = { const assembly = ConstructNode.synth(root.node, { outdir: fs.mkdtempSync(path.join(os.tmpdir(), 'outdir')) }); test.deepEqual(calls, [ 'prepare', 'validate', 'synthesize' ]); - const stack = assembly.getStack('art'); + const stack = assembly.getStackByName('art'); test.deepEqual(stack.template, { hello: 123 }); test.deepEqual(stack.templateFile, 'hey.json'); test.deepEqual(stack.parameters, { paramId: 'paramValue', paramId2: 'paramValue2' }); diff --git a/packages/@aws-cdk/core/test/util.ts b/packages/@aws-cdk/core/test/util.ts index e921f7b97d859..d029d31e495a7 100644 --- a/packages/@aws-cdk/core/test/util.ts +++ b/packages/@aws-cdk/core/test/util.ts @@ -1,5 +1,5 @@ import { ConstructNode, Stack } from '../lib'; export function toCloudFormation(stack: Stack): any { - return ConstructNode.synth(stack.node, { skipValidation: true }).getStack(stack.stackName).template; + return ConstructNode.synth(stack.node, { skipValidation: true }).getStackByName(stack.stackName).template; } diff --git a/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts b/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts index 814edebcdaa89..4161d2c446928 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts @@ -68,6 +68,12 @@ export interface AwsCloudFormationStackProperties { * Values for CloudFormation stack parameters that should be passed when the stack is deployed. */ readonly parameters?: { [id: string]: string }; + + /** + * The name to use for the CloudFormation stack. + * @default - name derived from artifact ID + */ + readonly stackName?: string; } /** diff --git a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts index 01779b2413ce0..ed10346fe9402 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts @@ -98,18 +98,49 @@ export class CloudAssembly { /** * Returns a CloudFormation stack artifact from this assembly. + * * @param stackName the name of the CloudFormation stack. * @throws if there is no stack artifact by that name + * @throws if there is more than one stack with the same stack name. You can + * use `getStackArtifact(stack.artifactId)` instead. * @returns a `CloudFormationStackArtifact` object. */ - public getStack(stackName: string): CloudFormationStackArtifact { - const artifact = this.tryGetArtifact(stackName); + public getStackByName(stackName: string): CloudFormationStackArtifact { + const artifacts = this.artifacts.filter(a => a instanceof CloudFormationStackArtifact && a.stackName === stackName); + if (!artifacts || artifacts.length === 0) { + throw new Error(`Unable to find stack with stack name "${stackName}"`); + } + + if (artifacts.length > 1) { + throw new Error(`There are multiple stacks with the stack name "${stackName}" (${artifacts.map(a => a.id).join(',')}). Use "getStackArtifact(id)" instead`); + } + + return artifacts[0] as CloudFormationStackArtifact; + } + + /** + * Returns a CloudFormation stack artifact by name from this assembly. + * @deprecated renamed to `getStackByName` (or `getStackArtifact(id)`) + */ + public getStack(stackName: string) { + return this.getStackByName(stackName); + } + + /** + * Returns a CloudFormation stack artifact from this assembly. + * + * @param artifactId the artifact id of the stack (can be obtained through `stack.artifactId`). + * @throws if there is no stack artifact with that id + * @returns a `CloudFormationStackArtifact` object. + */ + public getStackArtifact(artifactId: string): CloudFormationStackArtifact { + const artifact = this.tryGetArtifact(artifactId); if (!artifact) { - throw new Error(`Unable to find artifact with id "${stackName}"`); + throw new Error(`Unable to find artifact with id "${artifactId}"`); } if (!(artifact instanceof CloudFormationStackArtifact)) { - throw new Error(`Artifact ${stackName} is not a CloudFormation stack`); + throw new Error(`Artifact ${artifactId} is not a CloudFormation stack`); } return artifact; diff --git a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts b/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts index bc414a2792e41..4603cf7213080 100644 --- a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts @@ -32,7 +32,20 @@ export class CloudFormationStackArtifact extends CloudArtifact { public readonly parameters: { [id: string]: string }; /** - * The name of this stack. + * The physical name of this stack. + */ + public readonly stackName: string; + + /** + * A string that represents this stack. Should only be used in user interfaces. + * If the stackName and artifactId are the same, it will just return that. Otherwise, + * it will return something like " ()" + */ + public readonly displayName: string; + + /** + * The physical name of this stack. + * @deprecated renamed to `stackName` */ public readonly name: string; @@ -41,8 +54,8 @@ export class CloudFormationStackArtifact extends CloudArtifact { */ public readonly environment: Environment; - constructor(assembly: CloudAssembly, name: string, artifact: ArtifactManifest) { - super(assembly, name, artifact); + constructor(assembly: CloudAssembly, artifactId: string, artifact: ArtifactManifest) { + super(assembly, artifactId, artifact); if (!artifact.properties || !artifact.properties.templateFile) { throw new Error(`Invalid CloudFormation stack artifact. Missing "templateFile" property in cloud assembly manifest`); @@ -55,8 +68,15 @@ export class CloudFormationStackArtifact extends CloudArtifact { this.templateFile = properties.templateFile; this.parameters = properties.parameters || { }; - this.name = this.originalName = name; + this.stackName = properties.stackName || artifactId; this.template = JSON.parse(fs.readFileSync(path.join(this.assembly.directory, this.templateFile), 'utf-8')); this.assets = this.findMetadataByType(ASSET_METADATA).map(e => e.data); + + this.displayName = this.stackName === artifactId + ? this.stackName + : `${artifactId} (${this.stackName})`; + + this.name = this.stackName; // backwards compat + this.originalName = this.stackName; } } diff --git a/packages/@aws-cdk/cx-api/lib/cxapi.ts b/packages/@aws-cdk/cx-api/lib/cxapi.ts index 601322e9aefb0..33d83e74ea45c 100644 --- a/packages/@aws-cdk/cx-api/lib/cxapi.ts +++ b/packages/@aws-cdk/cx-api/lib/cxapi.ts @@ -28,29 +28,6 @@ export const CLI_ASM_VERSION_ENV = 'CDK_CLI_ASM_VERSION'; */ export const CLI_VERSION_ENV = 'CDK_CLI_VERSION'; -/** - * Enables the embedding of the "aws:cdk:path" in CloudFormation template metadata. - */ -export const PATH_METADATA_ENABLE_CONTEXT = 'aws:cdk:enable-path-metadata'; - -/** - * Disable the collection and reporting of version information. - */ -export const DISABLE_VERSION_REPORTING = 'aws:cdk:disable-version-reporting'; - -/** - * If this is set, asset staging is disabled. This means that assets will not be copied to - * the output directory and will be referenced with absolute source paths. - */ -export const DISABLE_ASSET_STAGING_CONTEXT = 'aws:cdk:disable-asset-staging'; - -/** - * If this context key is set, the CDK will stage assets under the specified - * directory. Otherwise, assets will not be staged. - * Omits stack traces from construct metadata entries. - */ -export const DISABLE_METADATA_STACK_TRACE = 'aws:cdk:disable-stack-trace'; - /** * If a context value is an object with this key, it indicates an error */ diff --git a/packages/@aws-cdk/cx-api/lib/features.ts b/packages/@aws-cdk/cx-api/lib/features.ts new file mode 100644 index 0000000000000..2d9eb8a40d7e4 --- /dev/null +++ b/packages/@aws-cdk/cx-api/lib/features.ts @@ -0,0 +1,35 @@ + +/** + * Enables the embedding of the "aws:cdk:path" in CloudFormation template metadata. + */ +export const PATH_METADATA_ENABLE_CONTEXT = 'aws:cdk:enable-path-metadata'; + +/** + * Disable the collection and reporting of version information. + */ +export const DISABLE_VERSION_REPORTING = 'aws:cdk:disable-version-reporting'; + +/** + * If this is set, asset staging is disabled. This means that assets will not be copied to + * the output directory and will be referenced with absolute source paths. + */ +export const DISABLE_ASSET_STAGING_CONTEXT = 'aws:cdk:disable-asset-staging'; + +/** + * If this context key is set, the CDK will stage assets under the specified + * directory. Otherwise, assets will not be staged. + * Omits stack traces from construct metadata entries. + */ +export const DISABLE_METADATA_STACK_TRACE = 'aws:cdk:disable-stack-trace'; + +/** + * If this is set, multiple stacks can use the same stack name (e.g. deployed to + * different environments). This means that the name of the synthesized template + * file will be based on the construct path and not on the defined `stackName` + * of the stack. + * + * This is a "future flag": the feature is disabled by default for backwards + * compatibility, but new projects created using `cdk init` will have this + * enabled through the generated `cdk.json`. + */ +export const ENABLE_STACK_NAME_DUPLICATES_CONTEXT = '@aws-cdk/core:enableStackNameDuplicates'; \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/lib/future.ts b/packages/@aws-cdk/cx-api/lib/future.ts new file mode 100644 index 0000000000000..a81d3be5d1dd3 --- /dev/null +++ b/packages/@aws-cdk/cx-api/lib/future.ts @@ -0,0 +1,18 @@ +import { ENABLE_STACK_NAME_DUPLICATES_CONTEXT } from "./features"; + +/** + * This map includes context keys and values for feature flags that enable + * capabilities "from the future", which we could not introduce as the default + * behavior due to backwards compatibility for existing projects. + * + * New projects generated through `cdk init` will include these flags in their + * generated `cdk.json` file. + * + * When we release the next major version of the CDK, we will flip the logic of + * these features and clean up the `cdk.json` generated by `cdk init`. + * + * Tests must cover the default (disabled) case and the future (enabled) case. + */ +export const FUTURE_FLAGS = { + [ENABLE_STACK_NAME_DUPLICATES_CONTEXT]: 'true' +}; \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/lib/index.ts b/packages/@aws-cdk/cx-api/lib/index.ts index 0b15109012855..26c5098614da6 100644 --- a/packages/@aws-cdk/cx-api/lib/index.ts +++ b/packages/@aws-cdk/cx-api/lib/index.ts @@ -11,5 +11,7 @@ export * from './cloud-assembly'; export * from './assets'; export * from './environment'; export * from './metadata'; +export * from './features'; +export * from './future'; export { CLOUD_ASSEMBLY_VERSION } from './versioning'; diff --git a/packages/@aws-cdk/cx-api/test/__snapshots__/cloud-assembly.test.js.snap b/packages/@aws-cdk/cx-api/test/__snapshots__/cloud-assembly.test.js.snap index 3549e8352e60e..3ae076caa47ba 100644 --- a/packages/@aws-cdk/cx-api/test/__snapshots__/cloud-assembly.test.js.snap +++ b/packages/@aws-cdk/cx-api/test/__snapshots__/cloud-assembly.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`assembly a single cloudformation stack and tree metadata 1`] = ` +exports[`assembly with a single cloudformation stack and tree metadata 1`] = ` Object { "environment": "aws://37736633/us-region-1", "properties": Object { @@ -10,7 +10,7 @@ Object { } `; -exports[`assembly a single cloudformation stack and tree metadata 2`] = ` +exports[`assembly with a single cloudformation stack and tree metadata 2`] = ` Object { "properties": Object { "file": "foo.tree.json", diff --git a/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts b/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts index ff4b01c0ff624..c1adf573cb596 100644 --- a/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts +++ b/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts @@ -98,7 +98,7 @@ test('cloud assembly builder', () => { }); // verify we have a template file - expect(assembly.getStack('minimal-artifact').template).toStrictEqual({ + expect(assembly.getStackByName('minimal-artifact').template).toStrictEqual({ Resources: { MyTopic: { Type: 'AWS::S3::Topic' diff --git a/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts b/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts index cbe03c8da96e0..3d3928b3319a5 100644 --- a/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts +++ b/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts @@ -14,7 +14,7 @@ test('empty assembly', () => { expect(assembly.tree()).toBeUndefined(); }); -test('assembly a single cloudformation stack and tree metadata', () => { +test('assembly with a single cloudformation stack and tree metadata', () => { const assembly = new CloudAssembly(path.join(FIXTURES, 'single-stack')); expect(assembly.artifacts).toHaveLength(2); expect(assembly.stacks).toHaveLength(1); @@ -31,7 +31,8 @@ test('assembly a single cloudformation stack and tree metadata', () => { expect(stack.messages).toEqual([]); expect(stack.manifest.metadata).toEqual(undefined); expect(stack.originalName).toEqual('MyStackName'); - expect(stack.name).toEqual('MyStackName'); + expect(stack.stackName).toEqual('MyStackName'); + expect(stack.id).toEqual('MyStackName'); const treeArtifact = assembly.tree(); expect(treeArtifact).toBeDefined(); @@ -100,7 +101,7 @@ test('dependencies', () => { expect(assembly.stacks).toHaveLength(4); // expect stacks to be listed in topological order - expect(assembly.stacks.map(s => s.name)).toEqual([ 'StackA', 'StackD', 'StackC', 'StackB' ]); + expect(assembly.stacks.map(s => s.id)).toEqual([ 'StackA', 'StackD', 'StackC', 'StackB' ]); expect(assembly.stacks[0].dependencies).toEqual([]); expect(assembly.stacks[1].dependencies).toEqual([]); expect(assembly.stacks[2].dependencies.map(x => x.id)).toEqual([ 'StackD' ]); @@ -116,4 +117,44 @@ test('verifyManifestVersion', () => { // tslint:disable-next-line:max-line-length expect(() => verifyManifestVersion('0.31.0')).toThrow(`The CDK CLI you are using requires your app to use CDK modules with version >= ${CLOUD_ASSEMBLY_VERSION}`); expect(() => verifyManifestVersion('99.99.99')).toThrow(`A newer version of the CDK CLI (>= 99.99.99) is necessary to interact with this app`); +}); + +test('stack artifacts can specify an explicit stack name that is different from the artifact id', () => { + const assembly = new CloudAssembly(path.join(FIXTURES, 'explicit-stack-name')); + + expect(assembly.getStackByName('TheStackName').stackName).toStrictEqual('TheStackName'); + expect(assembly.getStackByName('TheStackName').id).toStrictEqual('stackid1'); + + // deprecated but still test + expect(assembly.getStack('TheStackName').stackName).toStrictEqual('TheStackName'); + expect(assembly.getStack('TheStackName').id).toStrictEqual('stackid1'); +}); + +test('getStackByName fails if there are multiple stacks with the same name', () => { + const assembly = new CloudAssembly(path.join(FIXTURES, 'multiple-stacks-same-name')); + expect(() => assembly.getStackByName('the-physical-name-of-the-stack')).toThrow(/There are multiple stacks with the stack name \"the-physical-name-of-the-stack\" \(stack1\,stack2\)\. Use \"getStackArtifact\(id\)\" instead/); +}); + +test('getStackArtifact retrieves a stack by artifact id', () => { + const assembly = new CloudAssembly(path.join(FIXTURES, 'multiple-stacks-same-name')); + + expect(assembly.getStackArtifact('stack1').stackName).toEqual('the-physical-name-of-the-stack'); + expect(assembly.getStackArtifact('stack2').stackName).toEqual('the-physical-name-of-the-stack'); + expect(assembly.getStackArtifact('stack2').id).toEqual('stack2'); + expect(assembly.getStackArtifact('stack1').id).toEqual('stack1'); +}); + +test('displayName shows both artifact ID and stack name if needed', () => { + const a1 = new CloudAssembly(path.join(FIXTURES, 'multiple-stacks-same-name')); + expect(a1.getStackArtifact('stack1').displayName).toStrictEqual('stack1 (the-physical-name-of-the-stack)'); + expect(a1.getStackArtifact('stack2').displayName).toStrictEqual('stack2 (the-physical-name-of-the-stack)'); + + const a2 = new CloudAssembly(path.join(FIXTURES, 'single-stack')); + const art1 = a2.getStackArtifact('MyStackName'); + const art2 = a2.getStackByName('MyStackName'); + + expect(art1).toBe(art2); + expect(art1.displayName).toBe('MyStackName'); + expect(art1.id).toBe('MyStackName'); + expect(art1.stackName).toBe('MyStackName'); }); \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/fixtures/explicit-stack-name/manifest.json b/packages/@aws-cdk/cx-api/test/fixtures/explicit-stack-name/manifest.json new file mode 100644 index 0000000000000..e2ddbe6480221 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/fixtures/explicit-stack-name/manifest.json @@ -0,0 +1,19 @@ +{ + "version": "1.10.0", + "artifacts": { + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "foo.tree.json" + } + }, + "stackid1": { + "type": "aws:cloudformation:stack", + "environment": "aws://37736633/us-region-1", + "properties": { + "stackName": "TheStackName", + "templateFile": "template.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/fixtures/explicit-stack-name/template.json b/packages/@aws-cdk/cx-api/test/fixtures/explicit-stack-name/template.json new file mode 100644 index 0000000000000..284fd64cffc21 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/fixtures/explicit-stack-name/template.json @@ -0,0 +1,7 @@ +{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/manifest.json b/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/manifest.json new file mode 100644 index 0000000000000..229bc36a48ec8 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/manifest.json @@ -0,0 +1,21 @@ +{ + "version": "1.10.0", + "artifacts": { + "stack1": { + "type": "aws:cloudformation:stack", + "environment": "aws://37736633/us-region-1", + "properties": { + "stackName": "the-physical-name-of-the-stack", + "templateFile": "template.json" + } + }, + "stack2": { + "type": "aws:cloudformation:stack", + "environment": "aws://1111/us-region-1", + "properties": { + "stackName": "the-physical-name-of-the-stack", + "templateFile": "template.2.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/template.2.json b/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/template.2.json new file mode 100644 index 0000000000000..284fd64cffc21 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/template.2.json @@ -0,0 +1,7 @@ +{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/template.json b/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/template.json new file mode 100644 index 0000000000000..284fd64cffc21 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/fixtures/multiple-stacks-same-name/template.json @@ -0,0 +1,7 @@ +{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket" + } + } +} \ No newline at end of file diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 23ebee1d93265..ab3ec36ca5b48 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -287,12 +287,12 @@ async function initCommandLine() { * OUTPUT: If more than one stack ends up being selected, an output directory * should be supplied, where the templates will be written. */ - async function cliSynthesize(stackNames: string[], + async function cliSynthesize(stackIds: string[], exclusively: boolean): Promise { // Only autoselect dependencies if it doesn't interfere with user request or output options const autoSelectDependencies = !exclusively; - const stacks = await appStacks.selectStacks(stackNames, { + const stacks = await appStacks.selectStacks(stackIds, { extend: autoSelectDependencies ? ExtendedStackSelection.Upstream : ExtendedStackSelection.None, defaultBehavior: DefaultSelection.AllStacks }); @@ -318,7 +318,7 @@ async function initCommandLine() { // not outputting template to stdout, let's explain things to the user a little bit... success(`Successfully synthesized to ${colors.blue(path.resolve(appStacks.assembly!.directory))}`); - print(`Supply a stack name (${stacks.map(s => colors.green(s.name)).join(', ')}) to display its template.`); + print(`Supply a stack id (${stacks.map(s => colors.green(s.id)).join(', ')}) to display its template.`); return undefined; } @@ -331,16 +331,17 @@ async function initCommandLine() { const long = []; for (const stack of stacks) { long.push({ - name: stack.name, + id: stack.id, + name: stack.stackName, environment: stack.environment }); } return long; // will be YAML formatted output } - // just print stack names + // just print stack IDs for (const stack of stacks) { - data(stack.name); + data(stack.id); } return 0; // exit-code @@ -349,18 +350,18 @@ async function initCommandLine() { /** * Match a single stack from the list of available stacks */ - async function findStack(name: string): Promise { - const stacks = await appStacks.selectStacks([name], { + async function findStack(artifactId: string): Promise { + const stacks = await appStacks.selectStacks([artifactId], { extend: ExtendedStackSelection.None, defaultBehavior: DefaultSelection.None }); // Could have been a glob so check that we evaluated to exactly one if (stacks.length > 1) { - throw new Error(`This command requires exactly one stack and we matched more than one: ${stacks.map(x => x.name)}`); + throw new Error(`This command requires exactly one stack and we matched more than one: ${stacks.map(x => x.id)}`); } - return stacks[0].name; + return stacks[0].id; } function toJsonOrYaml(object: any): string { diff --git a/packages/aws-cdk/lib/api/bootstrap-environment.ts b/packages/aws-cdk/lib/api/bootstrap-environment.ts index bef027d5e4787..97805a8e23982 100644 --- a/packages/aws-cdk/lib/api/bootstrap-environment.ts +++ b/packages/aws-cdk/lib/api/bootstrap-environment.ts @@ -90,5 +90,5 @@ export async function bootstrapEnvironment(environment: cxapi.Environment, aws: }); const assembly = builder.buildAssembly(); - return await deployStack({ stack: assembly.getStack(toolkitStackName), sdk: aws, roleArn, tags: props.tags }); + return await deployStack({ stack: assembly.getStackByName(toolkitStackName), sdk: aws, roleArn, tags: props.tags }); } diff --git a/packages/aws-cdk/lib/api/cxapp/stacks.ts b/packages/aws-cdk/lib/api/cxapp/stacks.ts index 61066058d69e2..ba46dde630ab0 100644 --- a/packages/aws-cdk/lib/api/cxapp/stacks.ts +++ b/packages/aws-cdk/lib/api/cxapp/stacks.ts @@ -122,7 +122,7 @@ export class AppStacks { return stacks; } else { throw new Error(`Since this app includes more than a single stack, specify which stacks to use (wildcards are supported)\n` + - `Stacks: ${stacks.map(x => x.name).join(' ')}`); + `Stacks: ${stacks.map(x => x.id).join(' ')}`); } default: throw new Error(`invalid default behavior: ${options.defaultBehavior}`); @@ -131,7 +131,7 @@ export class AppStacks { const allStacks = new Map(); for (const stack of stacks) { - allStacks.set(stack.name, stack); + allStacks.set(stack.id, stack); } // For every selector argument, pick stacks from the list. @@ -140,8 +140,8 @@ export class AppStacks { let found = false; for (const stack of stacks) { - if (minimatch(stack.name, pattern) && !selectedStacks.has(stack.name)) { - selectedStacks.set(stack.name, stack); + if (minimatch(stack.id, pattern) && !selectedStacks.has(stack.id)) { + selectedStacks.set(stack.id, stack); found = true; } } @@ -162,7 +162,7 @@ export class AppStacks { } // Filter original array because it is in the right order - const selectedList = stacks.filter(s => selectedStacks.has(s.name)); + const selectedList = stacks.filter(s => selectedStacks.has(s.id)); return selectedList; } @@ -183,9 +183,9 @@ export class AppStacks { /** * Synthesize a single stack */ - public async synthesizeStack(stackName: string): Promise { + public async synthesizeStack(stackId: string): Promise { const resp = await this.synthesizeStacks(); - const stack = resp.getStack(stackName); + const stack = resp.getStackArtifact(stackId); return stack; } @@ -251,11 +251,11 @@ export class AppStacks { stack.template.Conditions[condName] = _makeCdkMetadataAvailableCondition(); stack.template.Resources.CDKMetadata.Condition = condName; } else { - warning(`The stack ${stack.name} already includes a ${condName} condition`); + warning(`The stack ${stack.id} already includes a ${condName} condition`); } } } else { - warning(`The stack ${stack.name} already includes a CDKMetadata resource`); + warning(`The stack ${stack.id} already includes a CDKMetadata resource`); } } } @@ -333,8 +333,8 @@ export class AppStacks { /** * Combine the names of a set of stacks using a comma */ -export function listStackNames(stacks: cxapi.CloudFormationStackArtifact[]): string { - return stacks.map(s => s.name).join(', '); +export function listStackIds(stacks: cxapi.CloudFormationStackArtifact[]): string { + return stacks.map(s => s.id).join(', '); } /** @@ -371,11 +371,11 @@ function includeDownstreamStacks( while (madeProgress) { madeProgress = false; - for (const [name, stack] of allStacks) { + for (const [id, stack] of allStacks) { // Select this stack if it's not selected yet AND it depends on a stack that's in the selected set - if (!selectedStacks.has(name) && (stack.dependencies || []).some(dep => selectedStacks.has(dep.id))) { - selectedStacks.set(name, stack); - added.push(name); + if (!selectedStacks.has(id) && (stack.dependencies || []).some(dep => selectedStacks.has(dep.id))) { + selectedStacks.set(id, stack); + added.push(id); madeProgress = true; } } @@ -401,10 +401,10 @@ function includeUpstreamStacks( for (const stack of selectedStacks.values()) { // Select an additional stack if it's not selected yet and a dependency of a selected stack (and exists, obviously) - for (const dependencyName of stack.dependencies.map(x => x.id)) { - if (!selectedStacks.has(dependencyName) && allStacks.has(dependencyName)) { - added.push(dependencyName); - selectedStacks.set(dependencyName, allStacks.get(dependencyName)!); + for (const dependencyId of stack.dependencies.map(x => x.id)) { + if (!selectedStacks.has(dependencyId) && allStacks.has(dependencyId)) { + added.push(dependencyId); + selectedStacks.set(dependencyId, allStacks.get(dependencyId)!); madeProgress = true; } } diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index ce16f06ed909f..dcc1cc739b3f2 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -44,12 +44,12 @@ const LARGE_TEMPLATE_SIZE_KB = 50; /** @experimental */ export async function deployStack(options: DeployStackOptions): Promise { if (!options.stack.environment) { - throw new Error(`The stack ${options.stack.name} does not have an environment`); + throw new Error(`The stack ${options.stack.displayName} does not have an environment`); } const params = await prepareAssets(options.stack, options.toolkitInfo, options.ci, options.reuseAssets); - const deployName = options.deployName || options.stack.name; + const deployName = options.deployName || options.stack.stackName; const executionId = uuid.v4(); @@ -127,7 +127,7 @@ async function getStackOutputs(cfn: aws.CloudFormation, stackName: string): Prom async function makeBodyParameter(stack: cxapi.CloudFormationStackArtifact, toolkitInfo?: ToolkitInfo): Promise { const templateJson = toYAML(stack.template); if (toolkitInfo) { - const s3KeyPrefix = `cdk/${stack.name}/`; + const s3KeyPrefix = `cdk/${stack.id}/`; const s3KeySuffix = '.yml'; const { key } = await toolkitInfo.uploadIfChanged(templateJson, { s3KeyPrefix, s3KeySuffix, contentType: 'application/x-yaml' @@ -137,7 +137,7 @@ async function makeBodyParameter(stack: cxapi.CloudFormationStackArtifact, toolk return { TemplateURL: templateURL }; } else if (templateJson.length > LARGE_TEMPLATE_SIZE_KB * 1024) { error( - `The template for stack "${stack.name}" is ${Math.round(templateJson.length / 1024)}KiB. ` + + `The template for stack "${stack.displayName}" is ${Math.round(templateJson.length / 1024)}KiB. ` + `Templates larger than ${LARGE_TEMPLATE_SIZE_KB}KiB must be uploaded to S3.\n` + 'Run the following command in order to setup an S3 bucket in this environment, and then re-deploy:\n\n', colors.blue(`\t$ cdk bootstrap ${stack.environment!.name}\n`)); @@ -160,10 +160,10 @@ export interface DestroyStackOptions { /** @experimental */ export async function destroyStack(options: DestroyStackOptions) { if (!options.stack.environment) { - throw new Error(`The stack ${options.stack.name} does not have an environment`); + throw new Error(`The stack ${options.stack.displayName} does not have an environment`); } - const deployName = options.deployName || options.stack.name; + const deployName = options.deployName || options.stack.stackName; const cfn = await options.sdk.cloudFormation(options.stack.environment.account, options.stack.environment.region, Mode.ForWriting); if (!await stackExists(cfn, deployName)) { return; diff --git a/packages/aws-cdk/lib/api/deployment-target.ts b/packages/aws-cdk/lib/api/deployment-target.ts index 5c950d95811c8..e37de6167d67c 100644 --- a/packages/aws-cdk/lib/api/deployment-target.ts +++ b/packages/aws-cdk/lib/api/deployment-target.ts @@ -48,14 +48,14 @@ export class CloudFormationDeploymentTarget implements IDeploymentTarget { } public async readCurrentTemplate(stack: CloudFormationStackArtifact): Promise