Skip to content

Commit ac6deb7

Browse files
committed
feat(cli): change set review on deploy
1 parent 755842d commit ac6deb7

File tree

15 files changed

+1689
-68
lines changed

15 files changed

+1689
-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
@@ -432,10 +432,34 @@ class FullCloudFormationDeployment {
432432
const changeSetName = deploymentMethod.changeSetName ?? 'cdk-deploy-change-set';
433433
const execute = deploymentMethod.execute ?? true;
434434
const importExistingResources = deploymentMethod.importExistingResources ?? false;
435-
const changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources);
435+
const executeExistingChangeSet = deploymentMethod.executeExistingChangeSet ?? false;
436+
437+
let changeSetDescription: DescribeChangeSetCommandOutput;
438+
439+
if (executeExistingChangeSet) {
440+
// Execute an existing change set instead of creating a new one
441+
await this.ioHelper.defaults.info(format('Executing existing change set %s on stack %s', changeSetName, this.stackName));
442+
changeSetDescription = await this.cfn.describeChangeSet({
443+
StackName: this.stackName,
444+
ChangeSetName: changeSetName,
445+
});
446+
447+
// Verify the change set exists and is in a valid state
448+
if (!changeSetDescription.ChangeSetId) {
449+
throw new ToolkitError(format('Change set %s not found on stack %s', changeSetName, this.stackName));
450+
}
451+
if (changeSetDescription.Status !== 'CREATE_COMPLETE') {
452+
throw new ToolkitError(format('Change set %s is in status %s and cannot be executed', changeSetName, changeSetDescription.Status));
453+
}
454+
} else {
455+
// Create a new change set (existing behavior)
456+
changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources);
457+
}
458+
436459
await this.updateTerminationProtection();
437460

438-
if (changeSetHasNoChanges(changeSetDescription)) {
461+
// Only check for empty changes when creating a new change set, not when executing an existing one
462+
if (!executeExistingChangeSet && changeSetHasNoChanges(changeSetDescription)) {
439463
await this.ioHelper.defaults.debug(format('No changes are to be performed on %s.', this.stackName));
440464
if (execute) {
441465
await this.ioHelper.defaults.debug(format('Deleting empty change set %s', changeSetDescription.ChangeSetId));
@@ -774,6 +798,13 @@ async function canSkipDeploy(
774798
return false;
775799
}
776800

801+
// Executing existing change set, never skip
802+
if (deployStackOptions.deploymentMethod?.method === 'change-set' &&
803+
deployStackOptions.deploymentMethod.executeExistingChangeSet === true) {
804+
await ioHelper.defaults.debug(`${deployName}: executing existing change set, never skip`);
805+
return false;
806+
}
807+
777808
// No existing stack
778809
if (!cloudFormationStack.exists) {
779810
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/cloud-assembly-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/deployments/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './deployment-result';
55
export * from './checks';
66
export { addMetadataAssetsToManifest } from './assets';
77
export { AssetManifestBuilder } from './asset-manifest-builder';
8+
export { changeSetHasNoChanges } from './cfn-api';

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
@@ -152,6 +152,10 @@ export const IO = {
152152
code: 'CDK_TOOLKIT_W5022',
153153
description: 'Empty existing stack, stack will be destroyed',
154154
}),
155+
CDK_TOOLKIT_W5023: make.warn({
156+
code: 'CDK_TOOLKIT_W5023',
157+
description: 'No changes to existing stack, deployment is skipped',
158+
}),
155159
CDK_TOOLKIT_I5031: make.info({
156160
code: 'CDK_TOOLKIT_I5031',
157161
description: 'Informs about any log groups that are traced as part of the deployment',
@@ -183,7 +187,7 @@ export const IO = {
183187
}),
184188
CDK_TOOLKIT_I5060: make.confirm<DeployConfirmationRequest>({
185189
code: 'CDK_TOOLKIT_I5060',
186-
description: 'Confirm deploy security sensitive changes',
190+
description: 'Confirm deploy changes',
187191
interface: 'DeployConfirmationRequest',
188192
}),
189193
CDK_TOOLKIT_I5100: make.info<StackDeployProgress>({

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

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as cxapi from '@aws-cdk/cloud-assembly-api';
44
import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema';
55
import { ArtifactType } from '@aws-cdk/cloud-assembly-schema';
66
import type { TemplateDiff } from '@aws-cdk/cloudformation-diff';
7+
import type { DescribeChangeSetCommandOutput } from '@aws-sdk/client-cloudformation';
78
import * as chalk from 'chalk';
89
import * as chokidar from 'chokidar';
910
import { type EventName, EVENTS } from 'chokidar/handler.js';
@@ -35,7 +36,7 @@ import type {
3536
EnvironmentBootstrapResult,
3637
} from '../actions/bootstrap';
3738
import { BootstrapSource } from '../actions/bootstrap';
38-
import { AssetBuildTime, type DeployOptions } from '../actions/deploy';
39+
import { AssetBuildTime, type DeploymentMethod, type DeployOptions } from '../actions/deploy';
3940
import {
4041
buildParameterMap,
4142
type PrivateDeployOptions,
@@ -66,7 +67,7 @@ import type { StackAssembly } from '../api/cloud-assembly/private';
6667
import { ALL_STACKS } from '../api/cloud-assembly/private';
6768
import { CloudAssemblySourceBuilder } from '../api/cloud-assembly/source-builder';
6869
import type { StackCollection } from '../api/cloud-assembly/stack-collection';
69-
import { Deployments } from '../api/deployments';
70+
import { changeSetHasNoChanges, Deployments } from '../api/deployments';
7071
import { DiffFormatter } from '../api/diff';
7172
import { detectStackDrift } from '../api/drift';
7273
import { DriftFormatter } from '../api/drift/drift-formatter';
@@ -614,32 +615,6 @@ export class Toolkit extends CloudAssemblySourceBuilder {
614615
return;
615616
}
616617

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

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

666-
let tags = options.tags;
667-
if (!tags || tags.length === 0) {
668-
tags = tagsForStack(stack);
669-
}
670-
671698
let deployDuration;
672699
try {
673700
let deployResult: SuccessfulDeployStackResult | undefined;
@@ -687,7 +714,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
687714
reuseAssets: options.reuseAssets,
688715
notificationArns,
689716
tags,
690-
deploymentMethod: options.deploymentMethod,
717+
deploymentMethod: deploymentMethod ?? options.deploymentMethod,
691718
forceDeployment: options.forceDeployment,
692719
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
693720
usePreviousParameters: options.parameters?.keepExistingParameters,

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)