Skip to content

Commit 25acc82

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

File tree

7 files changed

+618
-27
lines changed

7 files changed

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

0 commit comments

Comments
 (0)