Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/@aws-cdk/toolkit-lib/docs/message-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,14 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu
| `CDK_TOOLKIT_I5002` | Provides time for resource migration | `info` | {@link Duration} |
| `CDK_TOOLKIT_W5021` | Empty non-existent stack, deployment is skipped | `warn` | n/a |
| `CDK_TOOLKIT_W5022` | Empty existing stack, stack will be destroyed | `warn` | n/a |
| `CDK_TOOLKIT_W5023` | No changes to existing stack, deployment is skipped | `warn` | n/a |
| `CDK_TOOLKIT_I5031` | Informs about any log groups that are traced as part of the deployment | `info` | n/a |
| `CDK_TOOLKIT_I5032` | Start monitoring log groups | `debug` | {@link CloudWatchLogMonitorControlEvent} |
| `CDK_TOOLKIT_I5033` | A log event received from Cloud Watch | `info` | {@link CloudWatchLogEvent} |
| `CDK_TOOLKIT_I5034` | Stop monitoring log groups | `debug` | {@link CloudWatchLogMonitorControlEvent} |
| `CDK_TOOLKIT_E5035` | A log monitoring error | `error` | {@link ErrorPayload} |
| `CDK_TOOLKIT_I5050` | Confirm rollback during deployment | `info` | {@link ConfirmationRequest} |
| `CDK_TOOLKIT_I5060` | Confirm deploy security sensitive changes | `info` | {@link DeployConfirmationRequest} |
| `CDK_TOOLKIT_I5060` | Confirm deploy changes | `info` | {@link DeployConfirmationRequest} |
| `CDK_TOOLKIT_I5100` | Stack deploy progress | `info` | {@link StackDeployProgress} |
| `CDK_TOOLKIT_I5210` | Started building a specific asset | `trace` | {@link BuildAsset} |
| `CDK_TOOLKIT_I5211` | Building the asset has completed | `trace` | {@link Duration} |
Expand Down
35 changes: 35 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,41 @@ export interface ChangeSetDeployment {
* @default false
*/
readonly importExistingResources?: boolean;

/**
* Whether to execute an existing change set instead of creating a new one.
* When true, the specified changeSetName must exist and will be executed directly.
* When false or undefined, a new change set will be created.
*
* This is useful for secure change set review workflows where:
* 1. A change set is created with `execute: false`
* 2. The change set is reviewed by authorized personnel
* 3. The same change set is executed using this option to ensure
* the exact changes that were reviewed are deployed
*
* @example
* // Step 1: Create change set for review
* deployStack(\{
* deploymentMethod: \{
* method: 'change-set',
* changeSetName: 'my-review-changeset',
* execute: false
* \}
* \});
*
* // Step 2: Execute the reviewed change set
* deployStack(\{
* deploymentMethod: \{
* method: 'change-set',
* changeSetName: 'my-review-changeset',
* executeExistingChangeSet: true,
* execute: true
* \}
* \});
*
* @default false
*/
readonly executeExistingChangeSet?: boolean;
}

/**
Expand Down
35 changes: 33 additions & 2 deletions packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,10 +430,34 @@ class FullCloudFormationDeployment {
const changeSetName = deploymentMethod.changeSetName ?? 'cdk-deploy-change-set';
const execute = deploymentMethod.execute ?? true;
const importExistingResources = deploymentMethod.importExistingResources ?? false;
const changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources);
const executeExistingChangeSet = deploymentMethod.executeExistingChangeSet ?? false;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Support deploying/executing an existing CloudFormation change set.


let changeSetDescription: DescribeChangeSetCommandOutput;

if (executeExistingChangeSet) {
// Execute an existing change set instead of creating a new one
await this.ioHelper.defaults.info(format('Executing existing change set %s on stack %s', changeSetName, this.stackName));
changeSetDescription = await this.cfn.describeChangeSet({
StackName: this.stackName,
ChangeSetName: changeSetName,
});

// Verify the change set exists and is in a valid state
if (!changeSetDescription.ChangeSetId) {
throw new ToolkitError(format('Change set %s not found on stack %s', changeSetName, this.stackName));
}
if (changeSetDescription.Status !== 'CREATE_COMPLETE') {
throw new ToolkitError(format('Change set %s is in status %s and cannot be executed', changeSetName, changeSetDescription.Status));
}
} else {
// Create a new change set (existing behavior)
changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources);
}

await this.updateTerminationProtection();

if (changeSetHasNoChanges(changeSetDescription)) {
// Only check for empty changes when creating a new change set, not when executing an existing one
if (!executeExistingChangeSet && changeSetHasNoChanges(changeSetDescription)) {
await this.ioHelper.defaults.debug(format('No changes are to be performed on %s.', this.stackName));
if (execute) {
await this.ioHelper.defaults.debug(format('Deleting empty change set %s', changeSetDescription.ChangeSetId));
Expand Down Expand Up @@ -768,6 +792,13 @@ async function canSkipDeploy(
return false;
}

// Executing existing change set, never skip
if (deployStackOptions.deploymentMethod?.method === 'change-set' &&
deployStackOptions.deploymentMethod.executeExistingChangeSet === true) {
await ioHelper.defaults.debug(`${deployName}: executing existing change set, never skip`);
return false;
}

// No existing stack
if (!cloudFormationStack.exists) {
await ioHelper.defaults.debug(`${deployName}: no existing stack`);
Expand Down
29 changes: 29 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from 'crypto';
import * as cdk_assets from '@aws-cdk/cdk-assets-lib';
import type * as cxapi from '@aws-cdk/cx-api';
import type { DescribeChangeSetCommandOutput } from '@aws-sdk/client-cloudformation';
import * as chalk from 'chalk';
import { AssetManifestBuilder } from './asset-manifest-builder';
import {
Expand Down Expand Up @@ -674,6 +675,34 @@ export class Deployments {
return publisher.isEntryPublished(asset);
}

/**
* Read change set details for a stack
*/
public async describeChangeSet(
stackArtifact: cxapi.CloudFormationStackArtifact,
changeSetName: string,
): Promise<DescribeChangeSetCommandOutput> {
const env = await this.envs.accessStackForReadOnlyStackOperations(stackArtifact);
return env.sdk.cloudFormation().describeChangeSet({
StackName: stackArtifact.stackName,
ChangeSetName: changeSetName,
});
}

/**
* Delete a change set for a stack
*/
public async deleteChangeSet(
stackArtifact: cxapi.CloudFormationStackArtifact,
changeSetName: string,
): Promise<void> {
const env = await this.envs.accessStackForMutableStackOperations(stackArtifact);
await env.sdk.cloudFormation().deleteChangeSet({
StackName: stackArtifact.stackName,
ChangeSetName: changeSetName,
});
}

/**
* Validate that the bootstrap stack has the right version for this stack
*
Expand Down
7 changes: 7 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ interface FormatStackDiffOutput {
* Complete formatted diff
*/
readonly formattedDiff: string;

/**
* The type of permission changes in the stack diff.
* The IoHost will use this to decide whether or not to print.
*/
readonly permissionChangeType: PermissionChangeType;
}

/**
Expand Down Expand Up @@ -323,6 +329,7 @@ export class DiffFormatter {
return {
numStacksWithChanges,
formattedDiff,
permissionChangeType: this.permissionType(),
};
}

Expand Down
6 changes: 5 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ export const IO = {
code: 'CDK_TOOLKIT_W5022',
description: 'Empty existing stack, stack will be destroyed',
}),
CDK_TOOLKIT_W5023: make.warn({
code: 'CDK_TOOLKIT_W5023',
description: 'No changes to existing stack, deployment is skipped',
}),
CDK_TOOLKIT_I5031: make.info({
code: 'CDK_TOOLKIT_I5031',
description: 'Informs about any log groups that are traced as part of the deployment',
Expand Down Expand Up @@ -182,7 +186,7 @@ export const IO = {
}),
CDK_TOOLKIT_I5060: make.confirm<DeployConfirmationRequest>({
code: 'CDK_TOOLKIT_I5060',
description: 'Confirm deploy security sensitive changes',
description: 'Confirm deploy changes',
interface: 'DeployConfirmationRequest',
}),
CDK_TOOLKIT_I5100: make.info<StackDeployProgress>({
Expand Down
94 changes: 60 additions & 34 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema
import { ArtifactType } from '@aws-cdk/cloud-assembly-schema';
import type { TemplateDiff } from '@aws-cdk/cloudformation-diff';
import * as cxapi from '@aws-cdk/cx-api';
import type { DescribeChangeSetCommandOutput } from '@aws-sdk/client-cloudformation';
import * as chalk from 'chalk';
import * as chokidar from 'chokidar';
import * as fs from 'fs-extra';
Expand All @@ -19,7 +20,7 @@ import type {
EnvironmentBootstrapResult,
} from '../actions/bootstrap';
import { BootstrapSource } from '../actions/bootstrap';
import { AssetBuildTime, type DeployOptions } from '../actions/deploy';
import { AssetBuildTime, type DeploymentMethod, type DeployOptions } from '../actions/deploy';
import {
buildParameterMap,
type PrivateDeployOptions,
Expand Down Expand Up @@ -606,32 +607,6 @@ export class Toolkit extends CloudAssemblySourceBuilder {
return;
}

const currentTemplate = await deployments.readCurrentTemplate(stack);

const formatter = new DiffFormatter({
templateInfo: {
oldTemplate: currentTemplate,
newTemplate: stack,
},
});

const securityDiff = formatter.formatSecurityDiff();

// Send a request response with the formatted security diff as part of the message,
// and the template diff as data
// (IoHost decides whether to print depending on permissionChangeType)
const deployMotivation = '"--require-approval" is enabled and stack includes security-sensitive updates.';
const deployQuestion = `${securityDiff.formattedDiff}\n\n${deployMotivation}\nDo you wish to deploy these changes`;
const deployConfirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5060.req(deployQuestion, {
motivation: deployMotivation,
concurrency,
permissionChangeType: securityDiff.permissionChangeType,
templateDiffs: formatter.diffs,
}));
if (!deployConfirmed) {
throw new ToolkitError('Aborted by user');
}

// Following are the same semantics we apply with respect to Notification ARNs (dictated by the SDK)
//
// - undefined => cdk ignores it, as if it wasn't supported (allows external management).
Expand All @@ -647,6 +622,63 @@ export class Toolkit extends CloudAssemblySourceBuilder {
}
}

const tags = (options.tags && options.tags.length > 0) ? options.tags : tagsForStack(stack);

let deploymentMethod: DeploymentMethod | undefined;
let changeSet: DescribeChangeSetCommandOutput | undefined;
if (options.deploymentMethod?.method === 'change-set') {
// Create a CloudFormation change set
const changeSetName = options.deploymentMethod?.changeSetName || `cdk-deploy-change-set-${Date.now()}`;
await deployments.deployStack({
stack,
deployName: stack.stackName,
roleArn: options.roleArn,
toolkitStackName: this.toolkitStackName,
reuseAssets: options.reuseAssets,
notificationArns,
tags,
deploymentMethod: { method: 'change-set' as const, changeSetName, execute: false },
forceDeployment: options.forceDeployment,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
usePreviousParameters: options.parameters?.keepExistingParameters,
extraUserAgent: options.extraUserAgent,
assetParallelism: options.assetParallelism,
});

// Describe the change set to be presented to the user
changeSet = await deployments.describeChangeSet(stack, changeSetName);

// Don't continue deploying the stack if there are no changes (unless forced)
if (!options.forceDeployment && changeSet.ChangeSetName && (changeSet.Changes === undefined || changeSet.Changes.length === 0)) {
await deployments.deleteChangeSet(stack, changeSet.ChangeSetName);
return ioHelper.notify(IO.CDK_TOOLKIT_W5023.msg(`${chalk.bold(stack.displayName)}: stack has no changes, skipping deployment.`));
}

// Adjust the deployment method for the subsequent deployment to execute the existing change set
deploymentMethod = { ...options.deploymentMethod, changeSetName, executeExistingChangeSet: true };
}
// Present the diff to the user
const oldTemplate = await deployments.readCurrentTemplate(stack);
const formatter = new DiffFormatter({ templateInfo: { oldTemplate, newTemplate: stack, changeSet } });
const diff = formatter.formatStackDiff();

// Send a request response with the formatted diff as part of the message, and the template diff as data
// (IoHost decides whether to print depending on permissionChangeType)
const deployMotivation = 'Approval required for stack deployment.';
const deployQuestion = `${diff.formattedDiff}\n\n${deployMotivation}\nDo you wish to deploy these changes`;
const deployConfirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5060.req(deployQuestion, {
motivation: deployMotivation,
concurrency,
permissionChangeType: diff.permissionChangeType,
templateDiffs: formatter.diffs,
}));
if (!deployConfirmed) {
if (changeSet?.ChangeSetName) {
await deployments.deleteChangeSet(stack, changeSet.ChangeSetName);
}
throw new ToolkitError('Aborted by user');
}

const stackIndex = stacks.indexOf(stack) + 1;
const deploySpan = await ioHelper.span(SPAN.DEPLOY_STACK)
.begin(`${chalk.bold(stack.displayName)}: deploying... [${stackIndex}/${stackCollection.stackCount}]`, {
Expand All @@ -655,11 +687,6 @@ export class Toolkit extends CloudAssemblySourceBuilder {
stack,
});

let tags = options.tags;
if (!tags || tags.length === 0) {
tags = tagsForStack(stack);
}

let deployDuration;
try {
let deployResult: SuccessfulDeployStackResult | undefined;
Expand All @@ -679,7 +706,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
reuseAssets: options.reuseAssets,
notificationArns,
tags,
deploymentMethod: options.deploymentMethod,
deploymentMethod: deploymentMethod ?? options.deploymentMethod,
forceDeployment: options.forceDeployment,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
usePreviousParameters: options.parameters?.keepExistingParameters,
Expand Down Expand Up @@ -1392,4 +1419,3 @@ export class Toolkit extends CloudAssemblySourceBuilder {
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ jest.mock('../../lib/api/deployments', () => {
resolveEnvironment: jest.fn().mockResolvedValue({}),
isSingleAssetPublished: jest.fn().mockResolvedValue(true),
readCurrentTemplate: jest.fn().mockResolvedValue({ Resources: {} }),
describeChangeSet: jest.fn().mockResolvedValue({
ChangeSetName: 'test-changeset',
Changes: [],
Status: 'CREATE_COMPLETE',
}),
deleteChangeSet: jest.fn().mockResolvedValue({}),
})),
};
});
Expand Down
Loading