Skip to content

Commit 067539a

Browse files
authored
fix(CLI): cdk diff stack deletion causes a race condition (#29492)
*Co-authored by*: @scanlonp ### Issue # (if applicable) Closes #29265. ### Reason for this change Creating a changeset for a stack that has not been deployed yet causes CFN to create a stack in state `REVIEW_IN_PROGRESS`. Previously we deleted this empty stack, but did not wait for the stack status to be `DELETE_COMPLETE`. This allowed `cdk diff` to exit while the stack status was still `DELETE_IN_PROGRESS`, which can cause subsequent CDK commands to fail, because a stack deletion operation is still in progress. ### Description of changes No longer create the changeset if the stack doesn't exist. Only perform the existence check if the changeset parameter is specified, to avoid a permission error when looking up a stack. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 7fe8ad3 commit 067539a

File tree

5 files changed

+113
-37
lines changed

5 files changed

+113
-37
lines changed

packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts

+9
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function fullDiff(
4646
currentTemplate: { [key: string]: any },
4747
newTemplate: { [key: string]: any },
4848
changeSet?: CloudFormation.DescribeChangeSetOutput,
49+
isImport?: boolean,
4950
): types.TemplateDiff {
5051

5152
normalize(currentTemplate);
@@ -54,6 +55,8 @@ export function fullDiff(
5455
if (changeSet) {
5556
filterFalsePositives(theDiff, changeSet);
5657
addImportInformation(theDiff, changeSet);
58+
} else if (isImport) {
59+
makeAllResourceChangesImports(theDiff);
5760
}
5861

5962
return theDiff;
@@ -218,6 +221,12 @@ function addImportInformation(diff: types.TemplateDiff, changeSet: CloudFormatio
218221
});
219222
}
220223

224+
function makeAllResourceChangesImports(diff: types.TemplateDiff) {
225+
diff.resources.forEachDifference((_logicalId: string, change: types.ResourceDifference) => {
226+
change.isImport = true;
227+
});
228+
}
229+
221230
function filterFalsePositives(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
222231
const replacements = findResourceReplacements(changeSet);
223232
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {

packages/aws-cdk/lib/api/util/cloudformation.ts

+7-14
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ async function uploadBodyParameterAndCreateChangeSet(options: PrepareChangeSetOp
364364
}
365365

366366
async function createChangeSet(options: CreateChangeSetOptions): Promise<CloudFormation.DescribeChangeSetOutput> {
367-
await cleanupOldChangeset(options.exists, options.changeSetName, options.stack.stackName, options.cfn);
367+
await cleanupOldChangeset(options.changeSetName, options.stack.stackName, options.cfn);
368368

369369
debug(`Attempting to create ChangeSet with name ${options.changeSetName} for stack ${options.stack.stackName}`);
370370

@@ -388,23 +388,16 @@ async function createChangeSet(options: CreateChangeSetOptions): Promise<CloudFo
388388
debug('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id);
389389
// Fetching all pages if we'll execute, so we can have the correct change count when monitoring.
390390
const createdChangeSet = await waitForChangeSet(options.cfn, options.stack.stackName, options.changeSetName, { fetchAll: options.willExecute });
391-
await cleanupOldChangeset(options.exists, options.changeSetName, options.stack.stackName, options.cfn);
391+
await cleanupOldChangeset(options.changeSetName, options.stack.stackName, options.cfn);
392392

393393
return createdChangeSet;
394394
}
395395

396-
export async function cleanupOldChangeset(stackExists: boolean, changeSetName: string, stackName: string, cfn: CloudFormation) {
397-
if (stackExists) {
398-
// Delete any existing change sets generated by CDK since change set names must be unique.
399-
// The delete request is successful as long as the stack exists (even if the change set does not exist).
400-
debug(`Removing existing change set with name ${changeSetName} if it exists`);
401-
await cfn.deleteChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise();
402-
} else {
403-
// delete the stack since creating a changeset for a stack that doesn't exist leaves that stack in a REVIEW_IN_PROGRESS state
404-
// that prevents other changesets from being created, even after the changeset has been deleted.
405-
debug(`Removing stack with name ${stackName}`);
406-
await cfn.deleteStack({ StackName: stackName }).promise();
407-
}
396+
export async function cleanupOldChangeset(changeSetName: string, stackName: string, cfn: CloudFormation) {
397+
// Delete any existing change sets generated by CDK since change set names must be unique.
398+
// The delete request is successful as long as the stack exists (even if the change set does not exist).
399+
debug(`Removing existing change set with name ${changeSetName} if it exists`);
400+
await cfn.deleteChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise();
408401
}
409402

410403
/**

packages/aws-cdk/lib/cdk-toolkit.ts

+46-21
Original file line numberDiff line numberDiff line change
@@ -139,20 +139,32 @@ export class CdkToolkit {
139139
throw new Error(`There is no file at ${options.templatePath}`);
140140
}
141141

142-
const changeSet = options.changeSet ? await createDiffChangeSet({
143-
stack: stacks.firstStack,
144-
uuid: uuid.v4(),
145-
willExecute: false,
146-
deployments: this.props.deployments,
147-
sdkProvider: this.props.sdkProvider,
148-
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
149-
stream,
150-
}) : undefined;
142+
let changeSet = undefined;
143+
144+
if (options.changeSet) {
145+
const stackExists = await this.props.deployments.stackExists({
146+
stack: stacks.firstStack,
147+
deployName: stacks.firstStack.stackName,
148+
});
149+
if (stackExists) {
150+
changeSet = await createDiffChangeSet({
151+
stack: stacks.firstStack,
152+
uuid: uuid.v4(),
153+
deployments: this.props.deployments,
154+
willExecute: false,
155+
sdkProvider: this.props.sdkProvider,
156+
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
157+
stream,
158+
});
159+
} else {
160+
debug(`the stack '${stacks.firstStack.stackName}' has not been deployed to CloudFormation, skipping changeset creation.`);
161+
}
162+
}
151163

152164
const template = deserializeStructure(await fs.readFile(options.templatePath, { encoding: 'UTF-8' }));
153165
diffs = options.securityOnly
154166
? numberFromBool(printSecurityDiff(template, stacks.firstStack, RequireApproval.Broadening, changeSet))
155-
: printStackDiff(template, stacks.firstStack.template, strict, contextLines, quiet, changeSet, stream);
167+
: printStackDiff(template, stacks.firstStack.template, strict, contextLines, quiet, changeSet, false, stream);
156168
} else {
157169
// Compare N stacks against deployed templates
158170
for (const stack of stacks.stackArtifacts) {
@@ -171,16 +183,29 @@ export class CdkToolkit {
171183
removeNonImportResources(stack);
172184
}
173185

174-
const changeSet = options.changeSet ? await createDiffChangeSet({
175-
stack,
176-
uuid: uuid.v4(),
177-
deployments: this.props.deployments,
178-
willExecute: false,
179-
sdkProvider: this.props.sdkProvider,
180-
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
181-
resourcesToImport,
182-
stream,
183-
}) : undefined;
186+
let changeSet = undefined;
187+
188+
if (options.changeSet) {
189+
// only perform this check if we're going to make a changeset. This check requires permissions that --no-changeset users might not have.
190+
const stackExists = await this.props.deployments.stackExists({
191+
stack: stack,
192+
deployName: stack.stackName,
193+
});
194+
if (stackExists) {
195+
changeSet = await createDiffChangeSet({
196+
stack,
197+
uuid: uuid.v4(),
198+
deployments: this.props.deployments,
199+
willExecute: false,
200+
sdkProvider: this.props.sdkProvider,
201+
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
202+
resourcesToImport,
203+
stream,
204+
});
205+
} else {
206+
debug(`the stack '${stack.stackName}' has not been deployed to CloudFormation, skipping changeset creation.`);
207+
}
208+
}
184209

185210
if (resourcesToImport) {
186211
stream.write('Parameters and rules created during migration do not affect resource configuration.\n');
@@ -189,7 +214,7 @@ export class CdkToolkit {
189214
const stackCount =
190215
options.securityOnly
191216
? (numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening, changeSet)))
192-
: (printStackDiff(currentTemplate, stack, strict, contextLines, quiet, changeSet, stream, nestedStacks));
217+
: (printStackDiff(currentTemplate, stack, strict, contextLines, quiet, changeSet, !!resourcesToImport, stream, nestedStacks));
193218

194219
diffs += stackCount;
195220
}

packages/aws-cdk/lib/diff.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ export function printStackDiff(
2525
context: number,
2626
quiet: boolean,
2727
changeSet?: CloudFormation.DescribeChangeSetOutput,
28+
isImport?: boolean,
2829
stream: cfnDiff.FormatStream = process.stderr,
2930
nestedStackTemplates?: { [nestedStackLogicalId: string]: NestedStackTemplates }): number {
3031

31-
let diff = cfnDiff.fullDiff(oldTemplate, newTemplate.template, changeSet);
32+
let diff = cfnDiff.fullDiff(oldTemplate, newTemplate.template, changeSet, isImport);
3233

3334
// detect and filter out mangled characters from the diff
3435
let filteredChangesCount = 0;
@@ -82,6 +83,7 @@ export function printStackDiff(
8283
context,
8384
quiet,
8485
undefined,
86+
isImport,
8587
stream,
8688
nestedStack.nestedStackTemplates,
8789
);

packages/aws-cdk/test/diff.test.ts

+48-1
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,10 @@ describe('imports', () => {
9494
fs.rmSync('migrate.json');
9595
});
9696

97-
test('imports', async () => {
97+
test('imports render correctly for a nonexistant stack without creating a changeset', async () => {
9898
// GIVEN
9999
const buffer = new StringWritable();
100+
cloudFormation.stackExists = jest.fn().mockReturnValue(Promise.resolve(false));
100101

101102
// WHEN
102103
const exitCode = await toolkit.diff({
@@ -107,6 +108,34 @@ describe('imports', () => {
107108

108109
// THEN
109110
const plainTextOutput = buffer.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
111+
expect(cfn.createDiffChangeSet).not.toHaveBeenCalled();
112+
expect(plainTextOutput).toContain(`Stack A
113+
Parameters and rules created during migration do not affect resource configuration.
114+
Resources
115+
[←] AWS::SQS::Queue Queue import
116+
[←] AWS::SQS::Queue Queue2 import
117+
[←] AWS::S3::Bucket Bucket import
118+
`);
119+
120+
expect(buffer.data.trim()).toContain('✨ Number of stacks with differences: 1');
121+
expect(exitCode).toBe(0);
122+
});
123+
124+
test('imports render correctly for an existing stack and diff creates a changeset', async () => {
125+
// GIVEN
126+
const buffer = new StringWritable();
127+
cloudFormation.stackExists = jest.fn().mockReturnValue(Promise.resolve(true));
128+
129+
// WHEN
130+
const exitCode = await toolkit.diff({
131+
stackNames: ['A'],
132+
stream: buffer,
133+
changeSet: true,
134+
});
135+
136+
// THEN
137+
const plainTextOutput = buffer.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
138+
expect(cfn.createDiffChangeSet).toHaveBeenCalled();
110139
expect(plainTextOutput).toContain(`Stack A
111140
Parameters and rules created during migration do not affect resource configuration.
112141
Resources
@@ -306,6 +335,24 @@ describe('non-nested stacks', () => {
306335
expect(buffer.data.trim()).not.toContain('There were no differences');
307336
expect(exitCode).toBe(0);
308337
});
338+
339+
test('diff does not check for stack existence when --no-changeset is passed', async () => {
340+
// GIVEN
341+
const buffer = new StringWritable();
342+
343+
// WHEN
344+
const exitCode = await toolkit.diff({
345+
stackNames: ['A', 'A'],
346+
stream: buffer,
347+
fail: false,
348+
quiet: true,
349+
changeSet: false,
350+
});
351+
352+
// THEN
353+
expect(exitCode).toBe(0);
354+
expect(cloudFormation.stackExists).not.toHaveBeenCalled();
355+
});
309356
});
310357

311358
describe('nested stacks', () => {

0 commit comments

Comments
 (0)