Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { integTest, withDefaultFixture } from '../../../lib';

integTest(
'cdk diff --method=change-set does not leave stack in REVIEW_IN_PROGRESS',
withDefaultFixture(async (fixture) => {
const stackName = fixture.fullStackName('test-1');

// WHEN - diff against a stack that has not been deployed
await fixture.cdk(['diff', '--method=change-set', stackName]);

// THEN - the stack should be deleted or deleting (not stuck in REVIEW_IN_PROGRESS)
const status = await fixture.aws.stackStatus(stackName);
expect(status).not.toBe('REVIEW_IN_PROGRESS');
}),
);
15 changes: 13 additions & 2 deletions packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { format } from 'util';
import { randomUUID } from 'node:crypto';
import { format } from 'node:util';
import type { FileManifestEntry } from '@aws-cdk/cdk-assets-lib';
import { AssetManifest } from '@aws-cdk/cdk-assets-lib';
import * as cxapi from '@aws-cdk/cloud-assembly-api';
Expand Down Expand Up @@ -277,7 +278,7 @@ async function uploadBodyParameterAndCreateChangeSet(
const stack = await CloudFormationStack.lookup(cfn, options.stack.stackName, false);
// A stack in REVIEW_IN_PROGRESS was created by a previous CREATE changeset
// that was never executed. Treat it as non-existent for changeset purposes.
const exists = stack.exists && stack.stackStatus.name !== 'REVIEW_IN_PROGRESS';
const exists = stack.exists && stack.stackStatus.name !== 'REVIEW_IN_PROGRESS' && stack.stackStatus.name !== 'DELETE_IN_PROGRESS';

const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn);
await ioHelper.defaults.info(
Expand Down Expand Up @@ -384,6 +385,16 @@ async function createChangeSetAndCleanup(
changeSet.StackId ?? options.stack.stackName,
);

// If the stack didn't exist before, creating a CREATE changeset will have
// put it in REVIEW_IN_PROGRESS state. Delete the empty stack to clean up.
if (!options.exists) {
await ioHelper.defaults.debug(format('Deleting empty stack created by diff changeset: %s', changeSet.StackId ?? options.stack.stackName));
await options.cfn.deleteStack({
StackName: changeSet.StackId ?? options.stack.stackName,
ClientRequestToken: randomUUID(),
});
}

return createdChangeSet;
}

Expand Down
57 changes: 56 additions & 1 deletion packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from 'path';
import { CreateChangeSetCommand, DescribeChangeSetCommand, DescribeStacksCommand, GetTemplateCommand, ListStacksCommand } from '@aws-sdk/client-cloudformation';
import { CreateChangeSetCommand, DeleteStackCommand, DescribeChangeSetCommand, DescribeStacksCommand, GetTemplateCommand, ListStacksCommand } from '@aws-sdk/client-cloudformation';
import { GetParameterCommand } from '@aws-sdk/client-ssm';
import * as chalk from 'chalk';
import { DiffMethod } from '../../lib/actions/diff';
Expand Down Expand Up @@ -465,6 +465,61 @@ describe('diff', () => {
ChangeSetType: 'CREATE',
}));
});

test('ChangeSet diff deletes stack created in REVIEW_IN_PROGRESS for new stacks', async () => {
// GIVEN - stack doesn't exist
jest.spyOn(deployments.Deployments.prototype, 'stackExists').mockResolvedValue(false);
mockCloudFormationClient.on(DescribeStacksCommand).resolves({ Stacks: [] });
mockSSMClient.on(GetParameterCommand).resolves({ Parameter: { Value: '99' } });
mockCloudFormationClient.on(CreateChangeSetCommand).resolves({
Id: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/cdk-diff',
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/Stack1/fake-id',
});
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
Status: 'CREATE_COMPLETE',
Changes: [],
});

// WHEN
const cx = await cdkOutFixture(toolkit, 'stack-with-bucket');
await toolkit.diff(cx, {
stacks: { strategy: StackSelectionStrategy.ALL_STACKS },
method: DiffMethod.ChangeSet({ fallbackToTemplate: false }),
});

// THEN - the stack is deleted after the changeset is cleaned up
expect(mockCloudFormationClient).toHaveReceivedCommandWith(DeleteStackCommand, {
StackName: 'arn:aws:cloudformation:us-east-1:123456789012:stack/Stack1/fake-id',
});
});

test('ChangeSet diff treats DELETE_IN_PROGRESS stack as non-existent', async () => {
// GIVEN - stack is in DELETE_IN_PROGRESS (from a previous diff cleanup)
jest.spyOn(deployments.Deployments.prototype, 'stackExists').mockResolvedValue(true);
mockCloudFormationClient.on(DescribeStacksCommand).resolves({
Stacks: [{ StackName: 'Stack1', StackStatus: 'DELETE_IN_PROGRESS', CreationTime: new Date() }],
});
mockSSMClient.on(GetParameterCommand).resolves({ Parameter: { Value: '99' } });
mockCloudFormationClient.on(CreateChangeSetCommand).resolves({ Id: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/cdk-diff' });
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
Status: 'CREATE_COMPLETE',
Changes: [],
});

// WHEN
const cx = await cdkOutFixture(toolkit, 'stack-with-bucket');
await toolkit.diff(cx, {
stacks: { strategy: StackSelectionStrategy.ALL_STACKS },
method: DiffMethod.ChangeSet({ fallbackToTemplate: false }),
});

// THEN - a CREATE changeset was made (not UPDATE)
const createCalls = mockCloudFormationClient.commandCalls(CreateChangeSetCommand);
expect(createCalls).toHaveLength(1);
expect(createCalls[0].args[0].input).toEqual(expect.objectContaining({
ChangeSetType: 'CREATE',
}));
});
});

describe('DiffMethod.LocalFile', () => {
Expand Down
Loading