Skip to content

Commit 7920c88

Browse files
committed
feat(cli): change set review on deploy
1 parent 2f0cfc4 commit 7920c88

File tree

7 files changed

+646
-27
lines changed

7 files changed

+646
-27
lines changed

packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,41 @@ export interface ChangeSetDeployment {
3535
* @default false
3636
*/
3737
readonly importExistingResources?: boolean;
38+
39+
/**
40+
* Whether to execute an existing change set instead of creating a new one.
41+
* When true, the specified changeSetName must exist and will be executed directly.
42+
* When false or undefined, a new change set will be created.
43+
*
44+
* This is useful for secure change set review workflows where:
45+
* 1. A change set is created with `execute: false`
46+
* 2. The change set is reviewed by authorized personnel
47+
* 3. The same change set is executed using this option to ensure
48+
* the exact changes that were reviewed are deployed
49+
*
50+
* @example
51+
* // Step 1: Create change set for review
52+
* deployStack(\{
53+
* deploymentMethod: \{
54+
* method: 'change-set',
55+
* changeSetName: 'my-review-changeset',
56+
* execute: false
57+
* \}
58+
* \});
59+
*
60+
* // Step 2: Execute the reviewed change set
61+
* deployStack(\{
62+
* deploymentMethod: \{
63+
* method: 'change-set',
64+
* changeSetName: 'my-review-changeset',
65+
* executeExistingChangeSet: true,
66+
* execute: true
67+
* \}
68+
* \});
69+
*
70+
* @default false
71+
*/
72+
readonly executeExistingChangeSet?: boolean;
3873
}
3974

4075
/**

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,10 +430,34 @@ class FullCloudFormationDeployment {
430430
const changeSetName = deploymentMethod.changeSetName ?? 'cdk-deploy-change-set';
431431
const execute = deploymentMethod.execute ?? true;
432432
const importExistingResources = deploymentMethod.importExistingResources ?? false;
433-
const changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources);
433+
const executeExistingChangeSet = deploymentMethod.executeExistingChangeSet ?? false;
434+
435+
let changeSetDescription: DescribeChangeSetCommandOutput;
436+
437+
if (executeExistingChangeSet) {
438+
// Execute an existing change set instead of creating a new one
439+
await this.ioHelper.defaults.info(format('Executing existing change set %s on stack %s', changeSetName, this.stackName));
440+
changeSetDescription = await this.cfn.describeChangeSet({
441+
StackName: this.stackName,
442+
ChangeSetName: changeSetName,
443+
});
444+
445+
// Verify the change set exists and is in a valid state
446+
if (!changeSetDescription.ChangeSetId) {
447+
throw new ToolkitError(format('Change set %s not found on stack %s', changeSetName, this.stackName));
448+
}
449+
if (changeSetDescription.Status !== 'CREATE_COMPLETE') {
450+
throw new ToolkitError(format('Change set %s is in status %s and cannot be executed', changeSetName, changeSetDescription.Status));
451+
}
452+
} else {
453+
// Create a new change set (existing behavior)
454+
changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources);
455+
}
456+
434457
await this.updateTerminationProtection();
435458

436-
if (changeSetHasNoChanges(changeSetDescription)) {
459+
// Only check for empty changes when creating a new change set, not when executing an existing one
460+
if (!executeExistingChangeSet && changeSetHasNoChanges(changeSetDescription)) {
437461
await this.ioHelper.defaults.debug(format('No changes are to be performed on %s.', this.stackName));
438462
if (execute) {
439463
await this.ioHelper.defaults.debug(format('Deleting empty change set %s', changeSetDescription.ChangeSetId));
@@ -768,6 +792,13 @@ async function canSkipDeploy(
768792
return false;
769793
}
770794

795+
// Executing existing change set, never skip
796+
if (deployStackOptions.deploymentMethod?.method === 'change-set' &&
797+
deployStackOptions.deploymentMethod.executeExistingChangeSet === true) {
798+
await ioHelper.defaults.debug(`${deployName}: executing existing change set, never skip`);
799+
return false;
800+
}
801+
771802
// No existing stack
772803
if (!cloudFormationStack.exists) {
773804
await ioHelper.defaults.debug(`${deployName}: no existing stack`);

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { randomUUID } from 'crypto';
22
import * as cdk_assets from '@aws-cdk/cdk-assets-lib';
33
import type * as cxapi from '@aws-cdk/cx-api';
4+
import type { DescribeChangeSetCommandOutput } from '@aws-sdk/client-cloudformation';
45
import * as chalk from 'chalk';
56
import { AssetManifestBuilder } from './asset-manifest-builder';
67
import {
@@ -674,6 +675,34 @@ export class Deployments {
674675
return publisher.isEntryPublished(asset);
675676
}
676677

678+
/**
679+
* Read change set details for a stack
680+
*/
681+
public async describeChangeSet(
682+
stackArtifact: cxapi.CloudFormationStackArtifact,
683+
changeSetName: string,
684+
): Promise<DescribeChangeSetCommandOutput> {
685+
const env = await this.envs.accessStackForReadOnlyStackOperations(stackArtifact);
686+
return env.sdk.cloudFormation().describeChangeSet({
687+
StackName: stackArtifact.stackName,
688+
ChangeSetName: changeSetName,
689+
});
690+
}
691+
692+
/**
693+
* Delete a change set for a stack
694+
*/
695+
public async deleteChangeSet(
696+
stackArtifact: cxapi.CloudFormationStackArtifact,
697+
changeSetName: string,
698+
): Promise<void> {
699+
const env = await this.envs.accessStackForMutableStackOperations(stackArtifact);
700+
await env.sdk.cloudFormation().deleteChangeSet({
701+
StackName: stackArtifact.stackName,
702+
ChangeSetName: changeSetName,
703+
});
704+
}
705+
677706
/**
678707
* Validate that the bootstrap stack has the right version for this stack
679708
*

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import * as cxapi from '@aws-cdk/cx-api';
22
import {
33
ContinueUpdateRollbackCommand,
4+
DeleteChangeSetCommand,
45
DescribeStackEventsCommand,
56
DescribeStacksCommand,
67
ListStackResourcesCommand,
78
RollbackStackCommand,
89
type StackResourceSummary,
910
StackStatus,
1011
DescribeChangeSetCommand,
12+
ChangeAction,
1113
ChangeSetStatus,
1214
CreateChangeSetCommand,
1315
} from '@aws-sdk/client-cloudformation';
@@ -1215,3 +1217,107 @@ function givenStacks(stacks: Record<string, { template: any; stackStatus?: strin
12151217
}
12161218
});
12171219
}
1220+
1221+
describe('describeChangeSet', () => {
1222+
it('calls CloudFormation describeChangeSet with correct parameters', async () => {
1223+
// GIVEN
1224+
const changeSetName = 'test-changeset';
1225+
const stackName = 'test-stack';
1226+
const stack = testStack({ stackName });
1227+
const mockChangeSetResponse = {
1228+
ChangeSetId: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/test-changeset/12345',
1229+
ChangeSetName: changeSetName,
1230+
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/12345',
1231+
Status: ChangeSetStatus.CREATE_COMPLETE,
1232+
Changes: [{
1233+
Type: 'Resource' as const,
1234+
ResourceChange: {
1235+
Action: ChangeAction.Modify,
1236+
LogicalResourceId: 'TestResource',
1237+
ResourceType: 'AWS::S3::Bucket',
1238+
},
1239+
}],
1240+
};
1241+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves(mockChangeSetResponse);
1242+
1243+
// WHEN
1244+
const result = await deployments.describeChangeSet(stack, changeSetName);
1245+
1246+
// THEN
1247+
expect(result).toEqual(mockChangeSetResponse);
1248+
expect(mockCloudFormationClient).toHaveReceivedCommandWith(DescribeChangeSetCommand, {
1249+
StackName: stackName,
1250+
ChangeSetName: changeSetName,
1251+
});
1252+
});
1253+
1254+
it('handles CloudFormation errors gracefully', async () => {
1255+
// GIVEN
1256+
const changeSetName = 'non-existent-changeset';
1257+
const stackName = 'test-stack';
1258+
const stack = testStack({ stackName });
1259+
const error = new Error('Change set not found');
1260+
mockCloudFormationClient.on(DescribeChangeSetCommand).rejects(error);
1261+
1262+
// WHEN
1263+
const result = deployments.describeChangeSet(stack, changeSetName);
1264+
1265+
// THEN
1266+
await expect(result).rejects.toThrow('Change set not found');
1267+
});
1268+
1269+
it('returns the change set', async () => {
1270+
// GIVEN
1271+
const changeSetName = 'empty-changeset';
1272+
const stackName = 'test-stack';
1273+
const stack = testStack({ stackName });
1274+
const mockChangeSetResponse = {
1275+
ChangeSetId: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/empty-changeset/12345',
1276+
ChangeSetName: changeSetName,
1277+
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/12345',
1278+
Status: ChangeSetStatus.CREATE_COMPLETE,
1279+
};
1280+
1281+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves(mockChangeSetResponse);
1282+
1283+
// WHEN
1284+
const result = await deployments.describeChangeSet(stack, changeSetName);
1285+
1286+
// THEN
1287+
expect(result).toEqual(mockChangeSetResponse);
1288+
});
1289+
});
1290+
1291+
describe('deleteChangeSet', () => {
1292+
it('calls CloudFormation deleteChangeSet with correct parameters', async () => {
1293+
// GIVEN
1294+
const changeSetName = 'test-changeset';
1295+
const stackName = 'test-stack';
1296+
const stack = testStack({ stackName });
1297+
mockCloudFormationClient.on(DeleteChangeSetCommand).resolves({});
1298+
1299+
// WHEN
1300+
await deployments.deleteChangeSet(stack, changeSetName);
1301+
1302+
// THEN
1303+
expect(mockCloudFormationClient).toHaveReceivedCommandWith(DeleteChangeSetCommand, {
1304+
StackName: stackName,
1305+
ChangeSetName: changeSetName,
1306+
});
1307+
});
1308+
1309+
it('handles CloudFormation errors gracefully', async () => {
1310+
// GIVEN
1311+
const changeSetName = 'non-existent-changeset';
1312+
const stackName = 'test-stack';
1313+
const stack = testStack({ stackName });
1314+
const error = new Error('Change set not found');
1315+
mockCloudFormationClient.on(DeleteChangeSetCommand).rejects(error);
1316+
1317+
// WHEN
1318+
const result = deployments.deleteChangeSet(stack, changeSetName);
1319+
1320+
// THEN
1321+
await expect(result).rejects.toThrow('Change set not found');
1322+
});
1323+
});

0 commit comments

Comments
 (0)