Skip to content

Commit 97b1243

Browse files
committed
wip: working toward change set review on deploy
1 parent 2f0cfc4 commit 97b1243

File tree

18 files changed

+909
-16
lines changed

18 files changed

+909
-16
lines changed

packages/@aws-cdk/cli-lib-alpha/lib/commands/common.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export enum RequireApproval {
1616
* Only prompt for approval if there are security related changes
1717
*/
1818
BROADENING = 'broadening',
19+
20+
/**
21+
* Create and display a CloudFormation change set for review before deployment
22+
*/
23+
CHANGESET = 'change-set',
1924
}
2025

2126
/**

packages/@aws-cdk/cloud-assembly-schema/lib/integ-tests/commands/common.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export enum RequireApproval {
1616
* Only prompt for approval if there are security related changes
1717
*/
1818
BROADENING = 'broadening',
19+
20+
/**
21+
* Create and display a CloudFormation change set for review before deployment
22+
*/
23+
CHANGESET = 'change-set',
1924
}
2025

2126
/**

packages/@aws-cdk/cloud-assembly-schema/schema/integ.schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
"enum": [
172172
"any-change",
173173
"broadening",
174+
"change-set",
174175
"never"
175176
],
176177
"type": "string"
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"schemaHash": "4755f1d1fcb2dc25dd6bff0494afa7d86c517274ffebdf2ac2dcb90ad4b899c4",
2+
"schemaHash": "be3b4af15467897d18b5d4f52fafad5901ae65f99ba3fe9c5ebb661a3972dbe4",
33
"$comment": "Do not hold back the version on additions: jsonschema validation of the manifest by the consumer will trigger errors on unexpected fields.",
4-
"revision": 48
4+
"revision": 49
55
}

packages/@aws-cdk/cloud-assembly-schema/test/integ-tests.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,28 @@ describe('Integration test', () => {
9494
});
9595
});
9696

97+
test('valid input with change-set requireApproval', () => {
98+
expect(() => {
99+
validate({
100+
version: Manifest.version(),
101+
testCases: {
102+
testCase1: {
103+
stacks: ['stack1'],
104+
cdkCommandOptions: {
105+
deploy: {
106+
enabled: true,
107+
args: {
108+
requireApproval: 'change-set',
109+
app: 'node bin/my-app.js',
110+
},
111+
},
112+
},
113+
},
114+
},
115+
});
116+
}).not.toThrow();
117+
});
118+
97119
test('invalid input', () => {
98120
expect(() => {
99121
validate({

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 readChangeSet(
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: 118 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,120 @@ function givenStacks(stacks: Record<string, { template: any; stackStatus?: strin
12151216
}
12161217
});
12171218
}
1219+
1220+
describe('change set operations', () => {
1221+
test('readChangeSet calls CloudFormation describeChangeSet with correct parameters', async () => {
1222+
// GIVEN
1223+
const changeSetName = 'test-changeset';
1224+
const stackName = 'test-stack';
1225+
const mockChangeSetResponse = {
1226+
ChangeSetId: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/test-changeset/12345',
1227+
ChangeSetName: changeSetName,
1228+
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/12345',
1229+
Status: 'CREATE_COMPLETE',
1230+
Changes: [{
1231+
Type: 'Resource',
1232+
ResourceChange: {
1233+
Action: 'Modify',
1234+
LogicalResourceId: 'TestResource',
1235+
ResourceType: 'AWS::S3::Bucket',
1236+
},
1237+
}],
1238+
};
1239+
1240+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves(mockChangeSetResponse);
1241+
1242+
const stack = testStack({
1243+
stackName,
1244+
});
1245+
1246+
// WHEN
1247+
const result = await deployments.readChangeSet(stack, changeSetName);
1248+
1249+
// THEN
1250+
expect(result).toEqual(mockChangeSetResponse);
1251+
expect(mockCloudFormationClient).toHaveReceivedCommandWith(DescribeChangeSetCommand, {
1252+
StackName: stackName,
1253+
ChangeSetName: changeSetName,
1254+
});
1255+
});
1256+
1257+
test('deleteChangeSet calls CloudFormation deleteChangeSet with correct parameters', async () => {
1258+
// GIVEN
1259+
const changeSetName = 'test-changeset';
1260+
const stackName = 'test-stack';
1261+
1262+
mockCloudFormationClient.on(DeleteChangeSetCommand).resolves({});
1263+
1264+
const stack = testStack({
1265+
stackName,
1266+
});
1267+
1268+
// WHEN
1269+
await deployments.deleteChangeSet(stack, changeSetName);
1270+
1271+
// THEN
1272+
expect(mockCloudFormationClient).toHaveReceivedCommandWith(DeleteChangeSetCommand, {
1273+
StackName: stackName,
1274+
ChangeSetName: changeSetName,
1275+
});
1276+
});
1277+
1278+
test('readChangeSet handles CloudFormation errors gracefully', async () => {
1279+
// GIVEN
1280+
const changeSetName = 'non-existent-changeset';
1281+
const stackName = 'test-stack';
1282+
const error = new Error('Change set not found');
1283+
1284+
mockCloudFormationClient.on(DescribeChangeSetCommand).rejects(error);
1285+
1286+
const stack = testStack({
1287+
stackName,
1288+
});
1289+
1290+
// WHEN/THEN
1291+
await expect(deployments.readChangeSet(stack, changeSetName)).rejects.toThrow('Change set not found');
1292+
});
1293+
1294+
test('deleteChangeSet handles CloudFormation errors gracefully', async () => {
1295+
// GIVEN
1296+
const changeSetName = 'non-existent-changeset';
1297+
const stackName = 'test-stack';
1298+
const error = new Error('Change set not found');
1299+
1300+
mockCloudFormationClient.on(DeleteChangeSetCommand).rejects(error);
1301+
1302+
const stack = testStack({
1303+
stackName,
1304+
});
1305+
1306+
// WHEN/THEN
1307+
await expect(deployments.deleteChangeSet(stack, changeSetName)).rejects.toThrow('Change set not found');
1308+
});
1309+
1310+
test('readChangeSet returns change set with empty changes array', async () => {
1311+
// GIVEN
1312+
const changeSetName = 'empty-changeset';
1313+
const stackName = 'test-stack';
1314+
const mockChangeSetResponse = {
1315+
ChangeSetId: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/empty-changeset/12345',
1316+
ChangeSetName: changeSetName,
1317+
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/12345',
1318+
Status: 'CREATE_COMPLETE',
1319+
Changes: [],
1320+
};
1321+
1322+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves(mockChangeSetResponse);
1323+
1324+
const stack = testStack({
1325+
stackName,
1326+
});
1327+
1328+
// WHEN
1329+
const result = await deployments.readChangeSet(stack, changeSetName);
1330+
1331+
// THEN
1332+
expect(result).toEqual(mockChangeSetResponse);
1333+
expect(result.Changes).toHaveLength(0);
1334+
});
1335+
});

0 commit comments

Comments
 (0)