Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix(CLI): cdk diff stack deletion causes a race condition #29492

Merged
merged 16 commits into from
Mar 19, 2024
9 changes: 9 additions & 0 deletions packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function fullDiff(
currentTemplate: { [key: string]: any },
newTemplate: { [key: string]: any },
changeSet?: CloudFormation.DescribeChangeSetOutput,
isImport?: boolean,
): types.TemplateDiff {

normalize(currentTemplate);
Expand All @@ -54,6 +55,8 @@ export function fullDiff(
if (changeSet) {
filterFalsePositives(theDiff, changeSet);
addImportInformation(theDiff, changeSet);
} else if (isImport) {
makeAllResourceChangesImports(theDiff);
}

return theDiff;
Expand Down Expand Up @@ -218,6 +221,12 @@ function addImportInformation(diff: types.TemplateDiff, changeSet: CloudFormatio
});
}

function makeAllResourceChangesImports(diff: types.TemplateDiff) {
diff.resources.forEachDifference((_logicalId: string, change: types.ResourceDifference) => {
change.isImport = true;
});
}

function filterFalsePositives(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
const replacements = findResourceReplacements(changeSet);
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
Expand Down
21 changes: 7 additions & 14 deletions packages/aws-cdk/lib/api/util/cloudformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ async function uploadBodyParameterAndCreateChangeSet(options: PrepareChangeSetOp
}

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

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

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

return createdChangeSet;
}

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

/**
Expand Down
67 changes: 46 additions & 21 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,20 +136,32 @@ export class CdkToolkit {
throw new Error(`There is no file at ${options.templatePath}`);
}

const changeSet = options.changeSet ? await createDiffChangeSet({
stack: stacks.firstStack,
uuid: uuid.v4(),
willExecute: false,
deployments: this.props.deployments,
sdkProvider: this.props.sdkProvider,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
stream,
}) : undefined;
let changeSet = undefined;

if (options.changeSet) {
const stackExists = await this.props.deployments.stackExists({
stack: stacks.firstStack,
deployName: stacks.firstStack.stackName,
});
if (stackExists) {
changeSet = await createDiffChangeSet({
stack: stacks.firstStack,
uuid: uuid.v4(),
deployments: this.props.deployments,
willExecute: false,
sdkProvider: this.props.sdkProvider,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
stream,
});
} else {
debug(`the stack '${stacks.firstStack.stackName}' does not exist, skipping changeset creation.`);
scanlonp marked this conversation as resolved.
Show resolved Hide resolved
}
}

const template = deserializeStructure(await fs.readFile(options.templatePath, { encoding: 'UTF-8' }));
diffs = options.securityOnly
? numberFromBool(printSecurityDiff(template, stacks.firstStack, RequireApproval.Broadening, changeSet))
: printStackDiff(template, stacks.firstStack.template, strict, contextLines, quiet, changeSet, stream);
: printStackDiff(template, stacks.firstStack.template, strict, contextLines, quiet, changeSet, false, stream);
} else {
// Compare N stacks against deployed templates
for (const stack of stacks.stackArtifacts) {
Expand All @@ -168,16 +180,29 @@ export class CdkToolkit {
removeNonImportResources(stack);
}

const changeSet = options.changeSet ? await createDiffChangeSet({
stack,
uuid: uuid.v4(),
deployments: this.props.deployments,
willExecute: false,
sdkProvider: this.props.sdkProvider,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
resourcesToImport,
stream,
}) : undefined;
let changeSet = undefined;

if (options.changeSet) {
// only perform this check if we're going to make a changeset. This check requires permissions that --no-changeset users might not have.
const stackExists = await this.props.deployments.stackExists({
stack: stack,
deployName: stack.stackName,
});
if (stackExists) {
changeSet = await createDiffChangeSet({
stack,
uuid: uuid.v4(),
deployments: this.props.deployments,
willExecute: false,
sdkProvider: this.props.sdkProvider,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
resourcesToImport,
stream,
});
} else {
debug(`the stack '${stack.stackName}' does not exist, skipping changeset creation.`);
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
}
}

if (resourcesToImport) {
stream.write('Parameters and rules created during migration do not affect resource configuration.\n');
Expand All @@ -186,7 +211,7 @@ export class CdkToolkit {
const stackCount =
options.securityOnly
? (numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening, changeSet)))
: (printStackDiff(currentTemplate, stack, strict, contextLines, quiet, changeSet, stream, nestedStacks));
: (printStackDiff(currentTemplate, stack, strict, contextLines, quiet, changeSet, !!resourcesToImport, stream, nestedStacks));

diffs += stackCount;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/aws-cdk/lib/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ export function printStackDiff(
context: number,
quiet: boolean,
changeSet?: CloudFormation.DescribeChangeSetOutput,
isImport?: boolean,
stream: cfnDiff.FormatStream = process.stderr,
nestedStackTemplates?: { [nestedStackLogicalId: string]: NestedStackTemplates }): number {

let diff = cfnDiff.fullDiff(oldTemplate, newTemplate.template, changeSet);
let diff = cfnDiff.fullDiff(oldTemplate, newTemplate.template, changeSet, isImport);

// detect and filter out mangled characters from the diff
let filteredChangesCount = 0;
Expand Down Expand Up @@ -82,6 +83,7 @@ export function printStackDiff(
context,
quiet,
undefined,
isImport,
stream,
nestedStack.nestedStackTemplates,
);
Expand Down
49 changes: 48 additions & 1 deletion packages/aws-cdk/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,10 @@ describe('imports', () => {
fs.rmSync('migrate.json');
});

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

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

// THEN
const plainTextOutput = buffer.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
expect(cfn.createDiffChangeSet).not.toHaveBeenCalled();
expect(plainTextOutput).toContain(`Stack A
Parameters and rules created during migration do not affect resource configuration.
Resources
[←] AWS::SQS::Queue Queue import
[←] AWS::SQS::Queue Queue2 import
[←] AWS::S3::Bucket Bucket import
`);

expect(buffer.data.trim()).toContain('✨ Number of stacks with differences: 1');
expect(exitCode).toBe(0);
});

test('imports render correctly for an existing stack and diff creates a changeset', async () => {
// GIVEN
const buffer = new StringWritable();
cloudFormation.stackExists = jest.fn().mockReturnValue(Promise.resolve(true));

// WHEN
const exitCode = await toolkit.diff({
stackNames: ['A'],
stream: buffer,
changeSet: true,
});

// THEN
const plainTextOutput = buffer.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
expect(cfn.createDiffChangeSet).toHaveBeenCalled();
expect(plainTextOutput).toContain(`Stack A
Parameters and rules created during migration do not affect resource configuration.
Resources
Expand Down Expand Up @@ -306,6 +335,24 @@ describe('non-nested stacks', () => {
expect(buffer.data.trim()).not.toContain('There were no differences');
expect(exitCode).toBe(0);
});

test('diff does not check for stack existence when --no-changeset is passed', async () => {
// GIVEN
const buffer = new StringWritable();

// WHEN
const exitCode = await toolkit.diff({
stackNames: ['A', 'A'],
stream: buffer,
fail: false,
quiet: true,
changeSet: false,
});

// THEN
expect(exitCode).toBe(0);
expect(cloudFormation.stackExists).not.toHaveBeenCalled();
});
});

describe('nested stacks', () => {
Expand Down