Skip to content

Commit 562c273

Browse files
committed
feat(cli): change set review on deploy
1 parent a560d1e commit 562c273

File tree

14 files changed

+1714
-68
lines changed

14 files changed

+1714
-68
lines changed

packages/@aws-cdk/toolkit-lib/docs/message-registry.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,14 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu
8282
| `CDK_TOOLKIT_I5002` | Provides time for resource migration | `info` | {@link Duration} |
8383
| `CDK_TOOLKIT_W5021` | Empty non-existent stack, deployment is skipped | `warn` | n/a |
8484
| `CDK_TOOLKIT_W5022` | Empty existing stack, stack will be destroyed | `warn` | n/a |
85+
| `CDK_TOOLKIT_W5023` | No changes to existing stack, deployment is skipped | `warn` | n/a |
8586
| `CDK_TOOLKIT_I5031` | Informs about any log groups that are traced as part of the deployment | `info` | n/a |
8687
| `CDK_TOOLKIT_I5032` | Start monitoring log groups | `debug` | {@link CloudWatchLogMonitorControlEvent} |
8788
| `CDK_TOOLKIT_I5033` | A log event received from Cloud Watch | `info` | {@link CloudWatchLogEvent} |
8889
| `CDK_TOOLKIT_I5034` | Stop monitoring log groups | `debug` | {@link CloudWatchLogMonitorControlEvent} |
8990
| `CDK_TOOLKIT_E5035` | A log monitoring error | `error` | {@link ErrorPayload} |
9091
| `CDK_TOOLKIT_I5050` | Confirm rollback during deployment | `info` | {@link ConfirmationRequest} |
91-
| `CDK_TOOLKIT_I5060` | Confirm deploy security sensitive changes | `info` | {@link DeployConfirmationRequest} |
92+
| `CDK_TOOLKIT_I5060` | Confirm deploy changes | `info` | {@link DeployConfirmationRequest} |
9293
| `CDK_TOOLKIT_I5100` | Stack deploy progress | `info` | {@link StackDeployProgress} |
9394
| `CDK_TOOLKIT_I5210` | Started building a specific asset | `trace` | {@link BuildAsset} |
9495
| `CDK_TOOLKIT_I5211` | Building the asset has completed | `trace` | {@link Duration} |

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/lib/api/diff/diff-formatter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ interface FormatStackDiffOutput {
4343
* Complete formatted diff
4444
*/
4545
readonly formattedDiff: string;
46+
47+
/**
48+
* The type of permission changes in the stack diff.
49+
* The IoHost will use this to decide whether or not to print.
50+
*/
51+
readonly permissionChangeType: PermissionChangeType;
4652
}
4753

4854
/**
@@ -323,6 +329,7 @@ export class DiffFormatter {
323329
return {
324330
numStacksWithChanges,
325331
formattedDiff,
332+
permissionChangeType: this.permissionType(),
326333
};
327334
}
328335

packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ export const IO = {
151151
code: 'CDK_TOOLKIT_W5022',
152152
description: 'Empty existing stack, stack will be destroyed',
153153
}),
154+
CDK_TOOLKIT_W5023: make.warn({
155+
code: 'CDK_TOOLKIT_W5023',
156+
description: 'No changes to existing stack, deployment is skipped',
157+
}),
154158
CDK_TOOLKIT_I5031: make.info({
155159
code: 'CDK_TOOLKIT_I5031',
156160
description: 'Informs about any log groups that are traced as part of the deployment',
@@ -182,7 +186,7 @@ export const IO = {
182186
}),
183187
CDK_TOOLKIT_I5060: make.confirm<DeployConfirmationRequest>({
184188
code: 'CDK_TOOLKIT_I5060',
185-
description: 'Confirm deploy security sensitive changes',
189+
description: 'Confirm deploy changes',
186190
interface: 'DeployConfirmationRequest',
187191
}),
188192
CDK_TOOLKIT_I5100: make.info<StackDeployProgress>({

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

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema
44
import { ArtifactType } from '@aws-cdk/cloud-assembly-schema';
55
import type { TemplateDiff } from '@aws-cdk/cloudformation-diff';
66
import * as cxapi from '@aws-cdk/cx-api';
7+
import type { DescribeChangeSetCommandOutput } from '@aws-sdk/client-cloudformation';
78
import * as chalk from 'chalk';
89
import * as chokidar from 'chokidar';
910
import * as fs from 'fs-extra';
@@ -19,7 +20,7 @@ import type {
1920
EnvironmentBootstrapResult,
2021
} from '../actions/bootstrap';
2122
import { BootstrapSource } from '../actions/bootstrap';
22-
import { AssetBuildTime, type DeployOptions } from '../actions/deploy';
23+
import { AssetBuildTime, type DeploymentMethod, type DeployOptions } from '../actions/deploy';
2324
import {
2425
buildParameterMap,
2526
type PrivateDeployOptions,
@@ -606,32 +607,6 @@ export class Toolkit extends CloudAssemblySourceBuilder {
606607
return;
607608
}
608609

609-
const currentTemplate = await deployments.readCurrentTemplate(stack);
610-
611-
const formatter = new DiffFormatter({
612-
templateInfo: {
613-
oldTemplate: currentTemplate,
614-
newTemplate: stack,
615-
},
616-
});
617-
618-
const securityDiff = formatter.formatSecurityDiff();
619-
620-
// Send a request response with the formatted security diff as part of the message,
621-
// and the template diff as data
622-
// (IoHost decides whether to print depending on permissionChangeType)
623-
const deployMotivation = '"--require-approval" is enabled and stack includes security-sensitive updates.';
624-
const deployQuestion = `${securityDiff.formattedDiff}\n\n${deployMotivation}\nDo you wish to deploy these changes`;
625-
const deployConfirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5060.req(deployQuestion, {
626-
motivation: deployMotivation,
627-
concurrency,
628-
permissionChangeType: securityDiff.permissionChangeType,
629-
templateDiffs: formatter.diffs,
630-
}));
631-
if (!deployConfirmed) {
632-
throw new ToolkitError('Aborted by user');
633-
}
634-
635610
// Following are the same semantics we apply with respect to Notification ARNs (dictated by the SDK)
636611
//
637612
// - undefined => cdk ignores it, as if it wasn't supported (allows external management).
@@ -647,6 +622,63 @@ export class Toolkit extends CloudAssemblySourceBuilder {
647622
}
648623
}
649624

625+
const tags = (options.tags && options.tags.length > 0) ? options.tags : tagsForStack(stack);
626+
627+
let deploymentMethod: DeploymentMethod | undefined;
628+
let changeSet: DescribeChangeSetCommandOutput | undefined;
629+
if (options.deploymentMethod?.method === 'change-set') {
630+
// Create a CloudFormation change set
631+
const changeSetName = options.deploymentMethod?.changeSetName || `cdk-deploy-change-set-${Date.now()}`;
632+
await deployments.deployStack({
633+
stack,
634+
deployName: stack.stackName,
635+
roleArn: options.roleArn,
636+
toolkitStackName: this.toolkitStackName,
637+
reuseAssets: options.reuseAssets,
638+
notificationArns,
639+
tags,
640+
deploymentMethod: { method: 'change-set' as const, changeSetName, execute: false },
641+
forceDeployment: options.forceDeployment,
642+
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
643+
usePreviousParameters: options.parameters?.keepExistingParameters,
644+
extraUserAgent: options.extraUserAgent,
645+
assetParallelism: options.assetParallelism,
646+
});
647+
648+
// Describe the change set to be presented to the user
649+
changeSet = await deployments.describeChangeSet(stack, changeSetName);
650+
651+
// Don't continue deploying the stack if there are no changes (unless forced)
652+
if (!options.forceDeployment && changeSet.ChangeSetName && (changeSet.Changes === undefined || changeSet.Changes.length === 0)) {
653+
await deployments.deleteChangeSet(stack, changeSet.ChangeSetName);
654+
return ioHelper.notify(IO.CDK_TOOLKIT_W5023.msg(`${chalk.bold(stack.displayName)}: stack has no changes, skipping deployment.`));
655+
}
656+
657+
// Adjust the deployment method for the subsequent deployment to execute the existing change set
658+
deploymentMethod = { ...options.deploymentMethod, changeSetName, executeExistingChangeSet: true };
659+
}
660+
// Present the diff to the user
661+
const oldTemplate = await deployments.readCurrentTemplate(stack);
662+
const formatter = new DiffFormatter({ templateInfo: { oldTemplate, newTemplate: stack, changeSet } });
663+
const diff = formatter.formatStackDiff();
664+
665+
// Send a request response with the formatted diff as part of the message, and the template diff as data
666+
// (IoHost decides whether to print depending on permissionChangeType)
667+
const deployMotivation = 'Approval required for stack deployment.';
668+
const deployQuestion = `${diff.formattedDiff}\n\n${deployMotivation}\nDo you wish to deploy these changes`;
669+
const deployConfirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5060.req(deployQuestion, {
670+
motivation: deployMotivation,
671+
concurrency,
672+
permissionChangeType: diff.permissionChangeType,
673+
templateDiffs: formatter.diffs,
674+
}));
675+
if (!deployConfirmed) {
676+
if (changeSet?.ChangeSetName) {
677+
await deployments.deleteChangeSet(stack, changeSet.ChangeSetName);
678+
}
679+
throw new ToolkitError('Aborted by user');
680+
}
681+
650682
const stackIndex = stacks.indexOf(stack) + 1;
651683
const deploySpan = await ioHelper.span(SPAN.DEPLOY_STACK)
652684
.begin(`${chalk.bold(stack.displayName)}: deploying... [${stackIndex}/${stackCollection.stackCount}]`, {
@@ -655,11 +687,6 @@ export class Toolkit extends CloudAssemblySourceBuilder {
655687
stack,
656688
});
657689

658-
let tags = options.tags;
659-
if (!tags || tags.length === 0) {
660-
tags = tagsForStack(stack);
661-
}
662-
663690
let deployDuration;
664691
try {
665692
let deployResult: SuccessfulDeployStackResult | undefined;
@@ -679,7 +706,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
679706
reuseAssets: options.reuseAssets,
680707
notificationArns,
681708
tags,
682-
deploymentMethod: options.deploymentMethod,
709+
deploymentMethod: deploymentMethod ?? options.deploymentMethod,
683710
forceDeployment: options.forceDeployment,
684711
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
685712
usePreviousParameters: options.parameters?.keepExistingParameters,
@@ -1392,4 +1419,3 @@ export class Toolkit extends CloudAssemblySourceBuilder {
13921419
}
13931420
}
13941421
}
1395-

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ jest.mock('../../lib/api/deployments', () => {
1919
resolveEnvironment: jest.fn().mockResolvedValue({}),
2020
isSingleAssetPublished: jest.fn().mockResolvedValue(true),
2121
readCurrentTemplate: jest.fn().mockResolvedValue({ Resources: {} }),
22+
describeChangeSet: jest.fn().mockResolvedValue({
23+
ChangeSetName: 'test-changeset',
24+
Changes: [],
25+
Status: 'CREATE_COMPLETE',
26+
}),
27+
deleteChangeSet: jest.fn().mockResolvedValue({}),
2228
})),
2329
};
2430
});

0 commit comments

Comments
 (0)