Skip to content

Commit 1fe7075

Browse files
mrgrain9pace
andauthored
fix: "duplicate Export names" error deploying nested stacks with Fn::Join exports (#1307)
CDK CLI v2.1116.0 added `IncludeNestedStacks: true` to the deploy changeset creation path (PR #1292). This tells CloudFormation to validate all nested stack templates together when creating a changeset. However, CloudFormation cannot resolve intrinsic functions at changeset validation time. When multiple nested stacks use `Fn::Join` (or similar intrinsics) to build export names with runtime references, CloudFormation collapses all of them to the same placeholder `{{IntrinsicFunction://Fn::Join}}` and reports a false "duplicate Export names" error. This affects patterns like the Amplify GraphQL API construct which uses `Fn::Join` with an API ID to generate unique export names across nested stacks. This PR removes `IncludeNestedStacks` from the deploy changeset path, reverting to the pre-v2.1116.0 behavior. The diff changeset path (`cdk diff --method=change-set`) retains `IncludeNestedStacks` since `--method=auto` (the default) already falls back to template-based diff on error, and changeset diff didn't work for any nested stacks in previous versions so the current behavior is strictly an improvement. Internal reference D423570178 --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Co-authored-by: 9pace <paceben@amazon.com>
1 parent fa54d44 commit 1fe7075

File tree

6 files changed

+99
-13
lines changed

6 files changed

+99
-13
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const cdk = require('aws-cdk-lib');
2+
const { aws_cloudformation: cfn, aws_sns: sns, aws_ssm: ssm } = cdk;
3+
4+
/**
5+
* Reproduces https://t.corp.amazon.com/D423570178
6+
*
7+
* When IncludeNestedStacks is true, CloudFormation validates all nested stacks
8+
* together. Export names using Fn::Join with a runtime reference get collapsed
9+
* to the placeholder {{IntrinsicFunction://Fn::Join}}, causing a false
10+
* "duplicate Export names" error when 2+ such exports exist across nested stacks.
11+
*/
12+
13+
class NestedStackWithFnJoinExports extends cfn.NestedStack {
14+
constructor(scope, id, props) {
15+
super(scope, id, props);
16+
const topic = new sns.Topic(this, 'Topic');
17+
new cdk.CfnOutput(this, 'ExportArn', {
18+
value: topic.topicArn,
19+
exportName: cdk.Fn.join(':', [props.runtimeRef, 'GetAtt', id, 'Arn']),
20+
});
21+
new cdk.CfnOutput(this, 'ExportName', {
22+
value: topic.topicName,
23+
exportName: cdk.Fn.join(':', [props.runtimeRef, 'GetAtt', id, 'Name']),
24+
});
25+
}
26+
}
27+
28+
class NestedStacksFnJoinExportStack extends cdk.Stack {
29+
constructor(scope, id, props) {
30+
super(scope, id, props);
31+
// SSM parameter name is a runtime reference (not known at validation time)
32+
const param = new ssm.StringParameter(this, 'Param', { stringValue: 'value' });
33+
new NestedStackWithFnJoinExports(this, 'Nested1', { runtimeRef: param.parameterName });
34+
new NestedStackWithFnJoinExports(this, 'Nested2', { runtimeRef: param.parameterName });
35+
}
36+
}
37+
38+
const app = new cdk.App();
39+
40+
const stackPrefix = process.env.STACK_NAME_PREFIX;
41+
if (!stackPrefix) {
42+
throw new Error('the STACK_NAME_PREFIX environment variable is required');
43+
}
44+
45+
new NestedStacksFnJoinExportStack(app, `${stackPrefix}-nested-stacks-fn-join-export`);
46+
app.synth();
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"app": "node app.js",
3+
"versionReporting": false,
4+
"context": {
5+
"aws-cdk:enableDiffNoFail": "true"
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { integTest, withSpecificFixture } from '../../../lib';
2+
3+
integTest(
4+
'deploy nested stacks with Fn::Join export names',
5+
withSpecificFixture('nested-stack-with-fn-join-export', async (fixture) => {
6+
// This should succeed. With IncludeNestedStacks:true, CloudFormation
7+
// incorrectly reports duplicate export names when multiple nested stacks
8+
// use Fn::Join with the same runtime reference to build export names.
9+
await fixture.cdkDeploy('nested-stacks-fn-join-export', { captureStderr: false });
10+
}),
11+
);

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-deploy-skips-unnecessary-updates-for-nested-stacks.integtest.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { integTest, withDefaultFixture } from '../../../lib';
44
integTest(
55
'deploy skips unnecessary updates for nested stacks',
66
withDefaultFixture(async (fixture) => {
7-
// Deploy a stack with nested stacks. With IncludeNestedStacks, CloudFormation
8-
// can accurately detect whether nested stacks have actual changes, rather than
9-
// always reporting them as needing an update.
7+
// we are using a stack with a nested stack because CFN will always attempt to
8+
// update a nested stack, which will allow us to verify that updates are actually
9+
// skipped unless --force is specified.
1010
const stackArn = await fixture.cdkDeploy('with-nested-stack', { captureStderr: false });
1111
const changeSet1 = await getLatestChangeSet();
1212

@@ -15,15 +15,10 @@ integTest(
1515
const changeSet2 = await getLatestChangeSet();
1616
expect(changeSet2.ChangeSetId).toEqual(changeSet1.ChangeSetId);
1717

18-
// Deploy the stack again with --force. CloudFormation creates a changeset but
19-
// accurately reports no changes (including in nested stacks), so the changeset
20-
// is not executed and the stack's ChangeSetId remains the same.
21-
const forceOutput = await fixture.cdk(
22-
fixture.cdkDeployCommandLine('with-nested-stack', { options: ['--force'] }),
23-
);
24-
expect(forceOutput).toContain('CloudFormation reported that the deployment would not make any changes');
18+
// Deploy the stack again with --force, now we should create a changeset
19+
await fixture.cdkDeploy('with-nested-stack', { options: ['--force'] });
2520
const changeSet3 = await getLatestChangeSet();
26-
expect(changeSet3.ChangeSetId).toEqual(changeSet2.ChangeSetId);
21+
expect(changeSet3.ChangeSetId).not.toEqual(changeSet2.ChangeSetId);
2722

2823
// Deploy the stack again with tags, expected to create a new changeset
2924
// even though the resources didn't change.

packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { DeploymentError, DeploymentErrorCodes, ToolkitError } from '../../toolk
3131
import { formatErrorMessage } from '../../util';
3232
import type { SDK, SdkProvider, ICloudFormationClient } from '../aws-auth/private';
3333
import type { TemplateBodyParameter } from '../cloudformation';
34-
import { makeBodyParameter, CfnEvaluationException, CloudFormationStack, templateContainsNestedStacks } from '../cloudformation';
34+
import { makeBodyParameter, CfnEvaluationException, CloudFormationStack } from '../cloudformation';
3535
import type { EnvironmentResources, StringWithoutPlaceholders } from '../environment';
3636
import { EnvironmentResourcesRegistry } from '../environment';
3737
import { HotswapPropertyOverrides, ICON, createHotswapPropertyOverrides } from '../hotswap/common';
@@ -470,7 +470,6 @@ class FullCloudFormationDeployment {
470470
Description: `CDK Changeset for execution ${this.uuid}`,
471471
ClientToken: `create${this.uuid}`,
472472
ImportExistingResources: importExistingResources,
473-
IncludeNestedStacks: templateContainsNestedStacks(this.stackArtifact.template),
474473
DeploymentMode: revertDrift ? 'REVERT_DRIFT' : undefined,
475474
...this.commonPrepareOptions(),
476475
});

packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ const FAKE_STACK = testStack({
4848
stackName: 'withouterrors',
4949
});
5050

51+
const FAKE_STACK_WITH_NESTED_STACK = testStack({
52+
stackName: 'withnestedstack',
53+
template: {
54+
Resources: {
55+
NestedStack: {
56+
Type: 'AWS::CloudFormation::Stack',
57+
Properties: {
58+
TemplateURL: 'https://example.com/template.json',
59+
},
60+
},
61+
},
62+
},
63+
});
64+
5165
const FAKE_STACK_WITH_PARAMETERS = testStack({
5266
stackName: 'withparameters',
5367
template: {
@@ -1316,3 +1330,17 @@ function givenChangeSetContainsReplacement(replacement: boolean) {
13161330
] : [],
13171331
});
13181332
}
1333+
1334+
test('does not pass IncludeNestedStacks even for stacks with nested stacks', async () => {
1335+
// Regression test: IncludeNestedStacks causes CloudFormation to report false
1336+
// "duplicate Export names" errors when nested stacks use intrinsic functions
1337+
// (like Fn::Join) in export names.
1338+
await testDeployStack({
1339+
...standardDeployStackArguments(),
1340+
stack: FAKE_STACK_WITH_NESTED_STACK,
1341+
});
1342+
1343+
const calls = mockCloudFormationClient.commandCalls(CreateChangeSetCommand);
1344+
expect(calls.length).toBeGreaterThan(0);
1345+
expect(calls[0].args[0].input).not.toHaveProperty('IncludeNestedStacks');
1346+
});

0 commit comments

Comments
 (0)