Skip to content

Commit 38842d3

Browse files
committed
feat(deploy): add --method=execute-change-set for two-step deployment workflows\n\nAdd a new --method=execute-change-set option to cdk deploy that\nexecutes a previously created change set, bypassing synthesis and\nstack selection entirely. This enables a two-step deployment workflow:\n\n1. cdk deploy --method=prepare-change-set --change-set-name MyCS\n2. Review the change set\n3. cdk deploy MyStack --method=execute-change-set --change-set-name MyCS\n\nChanges across toolkit-lib and CLI:\n- New ExecuteChangeSetDeployment type in the DeploymentMethod union\n- New Toolkit.executeChangeSet() method that works directly with\n CloudFormation APIs without requiring a cloud assembly\n- CLI short-circuits to toolkit-lib for this method, not adding a new\n branch to CdkToolkit\n- Warns users about ignored options (--force, --parameters, etc.)\n- Requires --change-set-name and exactly one stack name
1 parent bec298a commit 38842d3

File tree

16 files changed

+282
-37
lines changed

16 files changed

+282
-37
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { DescribeChangeSetCommand, DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
2+
import { integTest, withDefaultFixture } from '../../../lib';
3+
4+
integTest(
5+
'two-step deploy: prepare then execute change set',
6+
withDefaultFixture(async (fixture) => {
7+
const changeSetName = `review-${fixture.stackNamePrefix}`;
8+
const stackName = 'test-2';
9+
const fullStackName = fixture.fullStackName(stackName);
10+
11+
// Step 1: Create the change set without executing it
12+
await fixture.cdkDeploy(stackName, {
13+
options: ['--method=prepare-change-set', '--change-set-name', changeSetName],
14+
captureStderr: false,
15+
});
16+
17+
// Verify the stack is in REVIEW_IN_PROGRESS
18+
const describeResponse = await fixture.aws.cloudFormation.send(
19+
new DescribeStacksCommand({ StackName: fullStackName }),
20+
);
21+
expect(describeResponse.Stacks?.[0].StackStatus).toEqual('REVIEW_IN_PROGRESS');
22+
23+
// Verify the change set exists and is ready
24+
const changeSetResponse = await fixture.aws.cloudFormation.send(
25+
new DescribeChangeSetCommand({
26+
StackName: fullStackName,
27+
ChangeSetName: changeSetName,
28+
}),
29+
);
30+
expect(changeSetResponse.Status).toEqual('CREATE_COMPLETE');
31+
32+
// Step 2: Execute the change set
33+
await fixture.cdk([
34+
'deploy',
35+
'--require-approval=never',
36+
'--method=execute-change-set',
37+
'--change-set-name', changeSetName,
38+
'--progress', 'events',
39+
fullStackName,
40+
]);
41+
42+
// Verify the stack is now deployed
43+
const finalResponse = await fixture.aws.cloudFormation.send(
44+
new DescribeStacksCommand({ StackName: fullStackName }),
45+
);
46+
expect(finalResponse.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE');
47+
}),
48+
);

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { StackSelector } from '../../api/cloud-assembly';
22
import type { Tag } from '../../api/tags';
33

4-
export type DeploymentMethod = DirectDeployment | ChangeSetDeployment | HotswapDeployment;
4+
export type DeploymentMethod = DirectDeployment | ChangeSetDeployment | ExecuteChangeSetDeployment | HotswapDeployment;
55

66
/**
77
* Use stack APIs to the deploy stack changes
@@ -44,6 +44,22 @@ export interface ChangeSetDeployment {
4444
readonly revertDrift?: boolean;
4545
}
4646

47+
/**
48+
* Execute an existing change set that was previously created
49+
*
50+
* This bypasses synthesis and stack selection entirely.
51+
* The stack name and change set name must refer to an existing change set
52+
* in REVIEW_IN_PROGRESS or CREATE_COMPLETE status.
53+
*/
54+
export interface ExecuteChangeSetDeployment {
55+
readonly method: 'execute-change-set';
56+
57+
/**
58+
* The name of the change set to execute.
59+
*/
60+
readonly changeSetName: string;
61+
}
62+
4763
/**
4864
* Perform a 'hotswap' deployment to deploy a stack changes
4965
*

packages/@aws-cdk/toolkit-lib/lib/actions/deploy/private/deployment-method.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
1-
import type { ChangeSetDeployment, DeploymentMethod } from '..';
1+
import type { ChangeSetDeployment, DeploymentMethod, ExecuteChangeSetDeployment } from '..';
22

33
export const DEFAULT_DEPLOY_CHANGE_SET_NAME = 'cdk-deploy-change-set';
44

5-
/**
6-
* Execute a previously created change set.
7-
* This is an internal deployment method used by the two-phase deploy flow.
8-
*/
9-
export interface ExecuteChangeSetDeployment {
10-
readonly method: 'execute-change-set';
11-
readonly changeSetName: string;
12-
}
13-
145
/**
156
* A change set deployment that will execute.
167
*/
@@ -42,6 +33,13 @@ export function isNonExecutingChangeSetDeployment(method?: DeploymentMethod): me
4233
return isChangeSetDeployment(method) && (method.execute === false);
4334
}
4435

36+
/**
37+
* Returns true if the deployment method is a execute-change-set deployment.
38+
*/
39+
export function isExecuteChangeSetDeployment(method?: DeploymentMethod): method is ExecuteChangeSetDeployment {
40+
return method?.method === 'execute-change-set';
41+
}
42+
4543
/**
4644
* Create an ExecuteChangeSetDeployment from a ChangeSetDeployment.
4745
*/

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import * as uuid from 'uuid';
1313
import { AssetManifestBuilder } from './asset-manifest-builder';
1414
import { publishAssets } from './asset-publishing';
1515
import { addMetadataAssetsToManifest } from './assets';
16-
import type {
16+
import {
17+
type ParameterChanges,
1718
ParameterValues,
18-
ParameterChanges,
1919
} from './cfn-api';
2020
import {
2121
changeSetHasNoChanges,
@@ -26,7 +26,7 @@ import {
2626
} from './cfn-api';
2727
import { determineAllowCrossAccountAssetPublishing } from './checks';
2828
import type { DeployStackResult, SuccessfulDeployStackResult } from './deployment-result';
29-
import type { ChangeSetDeployment, DeploymentMethod, DirectDeployment } from '../../actions/deploy';
29+
import type { ChangeSetDeployment, DeploymentMethod, DirectDeployment, ExecuteChangeSetDeployment } from '../../actions/deploy';
3030
import { DEFAULT_DEPLOY_CHANGE_SET_NAME } from '../../actions/deploy/private/deployment-method';
3131
import { DeploymentError, DeploymentErrorCodes, ToolkitError } from '../../toolkit/toolkit-error';
3232
import { formatErrorMessage } from '../../util';
@@ -41,7 +41,6 @@ import type { IoHelper } from '../io/private';
4141
import type { ResourcesToImport } from '../resource-import';
4242
import { StackActivityMonitor } from '../stack-events';
4343
import { EarlyValidationReporter } from './early-validation';
44-
import type { ExecuteChangeSetDeployment } from '../../actions/deploy/private/deployment-method';
4544

4645
export interface DeployStackOptions {
4746
/**
@@ -127,7 +126,7 @@ export interface DeployStackOptions {
127126
*
128127
* @default - Change set with defaults
129128
*/
130-
readonly deploymentMethod?: DeploymentMethod | ExecuteChangeSetDeployment;
129+
readonly deploymentMethod?: DeploymentMethod;
131130

132131
/**
133132
* The collection of extra parameters
@@ -194,12 +193,28 @@ export async function deployStack(options: DeployStackOptions, ioHelper: IoHelpe
194193
const stackArtifact = options.stack;
195194
const stackEnv = options.resolvedEnvironment;
196195

197-
let deploymentMethod = options.deploymentMethod ?? { method: 'change-set' };
196+
const inputMethod = options.deploymentMethod ?? { method: 'change-set' };
197+
let deploymentMethod: DeploymentMethod = inputMethod;
198+
198199
options.sdk.appendCustomUserAgent(options.extraUserAgent);
199200
const cfn = options.sdk.cloudFormation();
200201
const deployName = options.deployName || stackArtifact.stackName;
201202
let cloudFormationStack = await CloudFormationStack.lookup(cfn, deployName);
202203

204+
// execute-change-set: skip template/asset work, go straight to FullCloudFormationDeployment
205+
if (deploymentMethod.method === 'execute-change-set') {
206+
const fullDeployment = new FullCloudFormationDeployment(
207+
deploymentMethod,
208+
options,
209+
cloudFormationStack,
210+
stackArtifact,
211+
new ParameterValues({}, {}),
212+
{},
213+
ioHelper,
214+
);
215+
return fullDeployment.performDeployment();
216+
}
217+
203218
if (cloudFormationStack.stackStatus.isCreationFailure) {
204219
await ioHelper.defaults.debug(
205220
`Found existing stack ${deployName} that had previously failed creation. Deleting it before attempting to re-create it.`,

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { deployStack, destroyStack } from './deploy-stack';
1818
import type { DeployStackResult, SuccessfulDeployStackResult } from './deployment-result';
1919
import type { ChangeSetDeployment, DeploymentMethod } from '../../actions/deploy';
2020
import { DEFAULT_DEPLOY_CHANGE_SET_NAME } from '../../actions/deploy/private/deployment-method';
21-
import type { ExecuteChangeSetDeployment } from '../../actions/deploy/private/deployment-method';
2221
import { DeploymentError, ToolkitError } from '../../toolkit/toolkit-error';
2322
import { formatErrorMessage } from '../../util';
2423
import type { SdkProvider } from '../aws-auth/private';
@@ -92,7 +91,7 @@ export interface DeployStackOptions {
9291
*
9392
* @default - Change set with default options
9493
*/
95-
readonly deploymentMethod?: DeploymentMethod | ExecuteChangeSetDeployment;
94+
readonly deploymentMethod?: DeploymentMethod;
9695

9796
/**
9897
* Force deployment, even if the deployed template is identical to the one we are about to deploy.

packages/@aws-cdk/toolkit-lib/lib/api/work-graph/work-graph.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import type { IoHelper } from '../io/private';
66
export type Concurrency = number | Record<WorkNode['type'], number>;
77

88
export class WorkGraph {
9+
/**
10+
* A helper to declare a noop action.
11+
*/
12+
public static readonly NOOP: (..._: any[]) => any = () => {
13+
};
14+
915
public readonly nodes: Record<string, WorkNode>;
1016
private readonly readyPool: Array<WorkNode> = [];
1117
private readonly lazyDependencies = new Map<string, string[]>();

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { AssetBuildTime, type DeployOptions } from '../actions/deploy';
3939
import {
4040
buildParameterMap,
4141
isChangeSetDeployment,
42+
isExecuteChangeSetDeployment,
4243
isExecutingChangeSetDeployment,
4344
isNonExecutingChangeSetDeployment,
4445
type PrivateDeployOptions,
@@ -95,7 +96,7 @@ import { ResourceMigrator } from '../api/resource-import';
9596
import { tagsForStack } from '../api/tags/private';
9697
import { DEFAULT_TOOLKIT_STACK_NAME } from '../api/toolkit-info';
9798
import type { AssetBuildNode, AssetPublishNode, Concurrency, StackNode } from '../api/work-graph';
98-
import { WorkGraphBuilder, buildDestroyWorkGraph } from '../api/work-graph';
99+
import { WorkGraph, WorkGraphBuilder, buildDestroyWorkGraph } from '../api/work-graph';
99100
import type { AssemblyData, RefactorResult, StackDetails, SuccessfulDeployStackResult } from '../payloads';
100101
import { PermissionChangeType } from '../payloads';
101102
import { formatErrorMessage, formatTime, obscureTemplate, serializeStructure, validateSnsTopicArn } from '../util';
@@ -557,9 +558,8 @@ export class Toolkit extends CloudAssemblySourceBuilder {
557558
};
558559

559560
await workGraph.doParallel(graphConcurrency, {
560-
deployStack: async () => {
561-
// No-op: we're only publishing assets, not deploying
562-
},
561+
// No-op: we're only publishing assets, not deploying
562+
deployStack: WorkGraph.NOOP,
563563
buildAsset: this.createBuildAssetFunction(ioHelper, deployments, undefined),
564564
publishAsset: this.createPublishAssetFunction(ioHelper, deployments, undefined, options.force),
565565
});
@@ -620,9 +620,11 @@ export class Toolkit extends CloudAssemblySourceBuilder {
620620
}
621621

622622
const deployments = await this.deploymentsForAction('deploy');
623-
const migrator = new ResourceMigrator({ deployments, ioHelper });
624623

625-
await migrator.tryMigrateResources(stackCollection, options);
624+
if (!isExecuteChangeSetDeployment(options.deploymentMethod)) {
625+
const migrator = new ResourceMigrator({ deployments, ioHelper });
626+
await migrator.tryMigrateResources(stackCollection, options);
627+
}
626628

627629
const parameterMap = buildParameterMap(options.parameters?.parameters);
628630

@@ -637,6 +639,21 @@ export class Toolkit extends CloudAssemblySourceBuilder {
637639
const stackOutputs: { [key: string]: any } = {};
638640
const outputsFile = options.outputsFile;
639641

642+
const { buildAsset, publishAsset } = (() => {
643+
if (isExecuteChangeSetDeployment(options.deploymentMethod)) {
644+
// No-op: assets are already published
645+
return {
646+
buildAsset: WorkGraph.NOOP,
647+
publishAsset: WorkGraph.NOOP,
648+
};
649+
}
650+
651+
return {
652+
buildAsset: this.createBuildAssetFunction(ioHelper, deployments, options.roleArn),
653+
publishAsset: this.createPublishAssetFunction(ioHelper, deployments, options.roleArn, options.forceAssetPublishing),
654+
};
655+
})();
656+
640657
const deployStack = async (stackNode: StackNode) => {
641658
const stack = stackNode.stack;
642659
if (stackCollection.stackCount !== 1) {
@@ -926,8 +943,8 @@ export class Toolkit extends CloudAssemblySourceBuilder {
926943

927944
await workGraph.doParallel(graphConcurrency, {
928945
deployStack,
929-
buildAsset: this.createBuildAssetFunction(ioHelper, deployments, options.roleArn),
930-
publishAsset: this.createPublishAssetFunction(ioHelper, deployments, options.roleArn, options.forceAssetPublishing),
946+
buildAsset,
947+
publishAsset,
931948
});
932949

933950
return ret;

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,45 @@ test('call CreateStack when method=direct and the stack doesnt exist yet', async
256256
expect(mockCloudFormationClient).toHaveReceivedCommand(CreateStackCommand);
257257
});
258258

259+
test('execute-change-set describes and executes an existing change set', async () => {
260+
// GIVEN - stack exists
261+
mockCloudFormationClient.on(DescribeStacksCommand).resolves({
262+
Stacks: [{ ...baseResponse }],
263+
});
264+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
265+
Status: 'CREATE_COMPLETE',
266+
ChangeSetName: 'MyChangeSet',
267+
Changes: [],
268+
});
269+
270+
// WHEN
271+
await testDeployStack({
272+
...standardDeployStackArguments(),
273+
deploymentMethod: { method: 'execute-change-set', changeSetName: 'MyChangeSet' },
274+
});
275+
276+
// THEN - should execute the change set without creating a new one
277+
expect(mockCloudFormationClient).toHaveReceivedCommand(ExecuteChangeSetCommand);
278+
expect(mockCloudFormationClient).not.toHaveReceivedCommand(CreateChangeSetCommand);
279+
});
280+
281+
test('execute-change-set throws if change set is not ready', async () => {
282+
// GIVEN
283+
mockCloudFormationClient.on(DescribeStacksCommand).resolves({
284+
Stacks: [{ ...baseResponse }],
285+
});
286+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
287+
Status: 'FAILED',
288+
StatusReason: 'some reason',
289+
});
290+
291+
// WHEN/THEN
292+
await expect(testDeployStack({
293+
...standardDeployStackArguments(),
294+
deploymentMethod: { method: 'execute-change-set', changeSetName: 'MyChangeSet' },
295+
})).rejects.toThrow('not ready for execution');
296+
});
297+
259298
test('call UpdateStack when method=direct and the stack exists already', async () => {
260299
// WHEN
261300
mockCloudFormationClient.on(DescribeStacksCommand).resolves({

packages/aws-cdk/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,10 @@ be deployed and then executes it. This behavior can be controlled with the
409409
- `--method=prepare-change-set`: create the change set but don't execute it.
410410
This is useful if you have external tools that will inspect the change set or
411411
you have an approval process for change sets.
412+
- `--method=execute-change-set`: execute a previously created change set. This
413+
bypasses synthesis and stack selection entirely. Requires `--change-set-name`
414+
and exactly one stack name. This is useful for two-step deployment workflows
415+
where you first review a change set and then execute it.
412416
- `--method=direct`: do not create a change set but apply the change immediately.
413417
This is typically a bit faster than creating a change set, but it loses
414418
the progress information.
@@ -428,8 +432,21 @@ set to make it easier to later execute:
428432
$ cdk deploy --method=prepare-change-set --change-set-name MyChangeSetName
429433
```
430434

431-
For more control over when stack changes are deployed, the CDK can generate a
432-
CloudFormation change set but not execute it.
435+
To review a change set before executing it, use a two-step workflow:
436+
437+
```console
438+
$ # Step 1: Create the change set without executing it
439+
$ cdk deploy MyStack --method=prepare-change-set --change-set-name MyChangeSetName
440+
441+
$ # Step 2: Review the change set (e.g., in the AWS Console or via CLI)
442+
443+
$ # Step 3: Execute the change set with approval
444+
$ cdk deploy MyStack --method=execute-change-set --change-set-name MyChangeSetName
445+
```
446+
447+
When using `--method=execute-change-set`, options like `--force`, `--parameters`,
448+
`--no-rollback`, `--import-existing-resources`, and `--revert-drift` are ignored
449+
since the change set has already been created.
433450

434451
#### Import existing resources
435452

0 commit comments

Comments
 (0)