Skip to content

Commit d33caff

Browse files
authored
fix(cli): prevent changeset diff for non-deployed stacks (#29394)
### Reason for this change When a stack does not exist in CloudFormation, creating a changeset makes an empty `REVIEW_IN_PROGRESS` stack. We then call `delete-stack` to clean up the empty stack. However, this can cause a race condition with a deploy call. ### Description of changes This change prevents changeset diffs for stacks that do not yet exist in CloudFormation. This overrides the changeset diff flag. This change also adds logic for migrate stacks in the old diff logic to represent resource imports without needing the changeset present. ### Description of how you validated changes Testing with new stacks only uses changeset diffs once the stack is deployed. Testing with new migrate stacks only uses changeset diffs once deployed. Pre-deployment the resources correctly show as imports. Note: the deleted test assumes the diff will be calculated using the mocked changeset. The new logic avoids the changeset, so the test is no longer relevant. Closes #29265. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 54f0f43 commit d33caff

File tree

4 files changed

+48
-106
lines changed

4 files changed

+48
-106
lines changed

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

+23-6
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const DIFF_HANDLERS: HandlerRegistry = {
3737
* @param currentTemplate the current state of the stack.
3838
* @param newTemplate the target state of the stack.
3939
* @param changeSet the change set for this stack.
40+
* @param isImport if the stack is importing resources (a migrate stack).
4041
*
4142
* @returns a +types.TemplateDiff+ object that represents the changes that will happen if
4243
* a stack which current state is described by +currentTemplate+ is updated with
@@ -46,6 +47,7 @@ export function fullDiff(
4647
currentTemplate: { [key: string]: any },
4748
newTemplate: { [key: string]: any },
4849
changeSet?: CloudFormation.DescribeChangeSetOutput,
50+
isImport?: boolean,
4951
): types.TemplateDiff {
5052

5153
normalize(currentTemplate);
@@ -55,6 +57,9 @@ export function fullDiff(
5557
filterFalsePositivies(theDiff, changeSet);
5658
addImportInformation(theDiff, changeSet);
5759
}
60+
if (isImport) {
61+
addImportInformation(theDiff);
62+
}
5863

5964
return theDiff;
6065
}
@@ -209,13 +214,25 @@ function deepCopy(x: any): any {
209214
return x;
210215
}
211216

212-
function addImportInformation(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
213-
const imports = findResourceImports(changeSet);
214-
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
215-
if (imports.includes(logicalId)) {
217+
/**
218+
* Sets import flag to true for resource imports.
219+
* When the changeset parameter is not set, the stack is a new migrate stack,
220+
* so all resource changes are imports.
221+
*/
222+
function addImportInformation(diff: types.TemplateDiff, changeSet?: CloudFormation.DescribeChangeSetOutput) {
223+
if (changeSet) {
224+
const imports = findResourceImports(changeSet);
225+
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
226+
if (imports.includes(logicalId)) {
227+
change.isImport = true;
228+
}
229+
});
230+
} else {
231+
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
232+
logicalId; // dont know how to get past warning that this variable is not used.
216233
change.isImport = true;
217-
}
218-
});
234+
});
235+
}
219236
}
220237

221238
function filterFalsePositivies(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {

Diff for: packages/aws-cdk/lib/cdk-toolkit.ts

+22-4
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,14 @@ export class CdkToolkit {
136136
throw new Error(`There is no file at ${options.templatePath}`);
137137
}
138138

139-
const changeSet = options.changeSet ? await createDiffChangeSet({
139+
const stackExistsOptions = {
140+
stack: stacks.firstStack,
141+
deployName: stacks.firstStack.stackName,
142+
};
143+
144+
const stackExists = await this.props.deployments.stackExists(stackExistsOptions);
145+
146+
const changeSet = (stackExists && options.changeSet) ? await createDiffChangeSet({
140147
stack: stacks.firstStack,
141148
uuid: uuid.v4(),
142149
willExecute: false,
@@ -168,13 +175,23 @@ export class CdkToolkit {
168175
removeNonImportResources(stack);
169176
}
170177

171-
const changeSet = options.changeSet ? await createDiffChangeSet({
178+
const stackExistsOptions = {
179+
stack,
180+
deployName: stack.stackName,
181+
};
182+
183+
const stackExists = await this.props.deployments.stackExists(stackExistsOptions);
184+
185+
// if the stack does not already exist, do not do a changeset
186+
// this prevents race conditions between deleting the dummy changeset stack and deploying the real changeset stack
187+
// migrate stacks that import resources will not previously exist and default to old diff logic
188+
const changeSet = (stackExists && options.changeSet) ? await createDiffChangeSet({
172189
stack,
173190
uuid: uuid.v4(),
174191
deployments: this.props.deployments,
175192
willExecute: false,
176193
sdkProvider: this.props.sdkProvider,
177-
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
194+
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]), // should this be stack?
178195
resourcesToImport,
179196
stream,
180197
}) : undefined;
@@ -183,10 +200,11 @@ export class CdkToolkit {
183200
stream.write('Parameters and rules created during migration do not affect resource configuration.\n');
184201
}
185202

203+
// pass a boolean to print if the stack is a migrate stack in order to set all resource diffs to import
186204
const stackCount =
187205
options.securityOnly
188206
? (numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening, changeSet)) > 0 ? 1 : 0)
189-
: (printStackDiff(currentTemplate, stack, strict, contextLines, quiet, changeSet, stream) > 0 ? 1 : 0);
207+
: (printStackDiff(currentTemplate, stack, strict, contextLines, quiet, changeSet, stream, !!resourcesToImport) > 0 ? 1 : 0);
190208

191209
diffs += stackCount + nestedStackCount;
192210
}

Diff for: packages/aws-cdk/lib/diff.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ export function printStackDiff(
2323
context: number,
2424
quiet: boolean,
2525
changeSet?: CloudFormation.DescribeChangeSetOutput,
26-
stream?: cfnDiff.FormatStream): number {
26+
stream?: cfnDiff.FormatStream,
27+
isImport?: boolean): number {
2728

28-
let diff = cfnDiff.fullDiff(oldTemplate, newTemplate.template, changeSet);
29+
let diff = cfnDiff.fullDiff(oldTemplate, newTemplate.template, changeSet, isImport);
2930

3031
// detect and filter out mangled characters from the diff
3132
let filteredChangesCount = 0;

Diff for: packages/aws-cdk/test/diff.test.ts

-94
Original file line numberDiff line numberDiff line change
@@ -12,100 +12,6 @@ let cloudExecutable: MockCloudExecutable;
1212
let cloudFormation: jest.Mocked<Deployments>;
1313
let toolkit: CdkToolkit;
1414

15-
describe('imports', () => {
16-
beforeEach(() => {
17-
jest.spyOn(cfn, 'createDiffChangeSet').mockImplementation(async () => {
18-
return {
19-
Changes: [
20-
{
21-
ResourceChange: {
22-
Action: 'Import',
23-
LogicalResourceId: 'Queue',
24-
},
25-
},
26-
{
27-
ResourceChange: {
28-
Action: 'Import',
29-
LogicalResourceId: 'Bucket',
30-
},
31-
},
32-
{
33-
ResourceChange: {
34-
Action: 'Import',
35-
LogicalResourceId: 'Queue2',
36-
},
37-
},
38-
],
39-
};
40-
});
41-
cloudExecutable = new MockCloudExecutable({
42-
stacks: [{
43-
stackName: 'A',
44-
template: {
45-
Resources: {
46-
Queue: {
47-
Type: 'AWS::SQS::Queue',
48-
},
49-
Queue2: {
50-
Type: 'AWS::SQS::Queue',
51-
},
52-
Bucket: {
53-
Type: 'AWS::S3::Bucket',
54-
},
55-
},
56-
},
57-
}],
58-
});
59-
60-
cloudFormation = instanceMockFrom(Deployments);
61-
62-
toolkit = new CdkToolkit({
63-
cloudExecutable,
64-
deployments: cloudFormation,
65-
configuration: cloudExecutable.configuration,
66-
sdkProvider: cloudExecutable.sdkProvider,
67-
});
68-
69-
// Default implementations
70-
cloudFormation.readCurrentTemplateWithNestedStacks.mockImplementation((_stackArtifact: CloudFormationStackArtifact) => {
71-
return Promise.resolve({
72-
deployedTemplate: {},
73-
nestedStackCount: 0,
74-
});
75-
});
76-
cloudFormation.deployStack.mockImplementation((options) => Promise.resolve({
77-
noOp: true,
78-
outputs: {},
79-
stackArn: '',
80-
stackArtifact: options.stack,
81-
}));
82-
});
83-
84-
test('imports', async () => {
85-
// GIVEN
86-
const buffer = new StringWritable();
87-
88-
// WHEN
89-
const exitCode = await toolkit.diff({
90-
stackNames: ['A'],
91-
stream: buffer,
92-
changeSet: true,
93-
});
94-
95-
// THEN
96-
const plainTextOutput = buffer.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
97-
expect(plainTextOutput).toContain(`Stack A
98-
Resources
99-
[←] AWS::SQS::Queue Queue import
100-
[←] AWS::SQS::Queue Queue2 import
101-
[←] AWS::S3::Bucket Bucket import
102-
`);
103-
104-
expect(buffer.data.trim()).toContain('✨ Number of stacks with differences: 1');
105-
expect(exitCode).toBe(0);
106-
});
107-
});
108-
10915
describe('non-nested stacks', () => {
11016
beforeEach(() => {
11117
cloudExecutable = new MockCloudExecutable({

0 commit comments

Comments
 (0)