From fdd1f7d9e0bbb33da9b7f4d618aecd9ebdd5368d Mon Sep 17 00:00:00 2001 From: aki-kii Date: Sun, 8 Feb 2026 22:07:35 +0900 Subject: [PATCH 1/2] feat(cli): add revert-drift option support to deploy command --- .../cdk-deploy-with-revert-drift.integtest.ts | 55 +++++++++++++++++++ ...throws-when-drift-is-detected.integtest.ts | 36 +----------- .../drift/cdk-cdk-drift.integtest.ts | 36 +----------- .../cli-integ-tests/drift/drift_helpers.ts | 33 +++++++++++ .../toolkit-lib/lib/actions/deploy/index.ts | 7 +++ .../lib/api/deployments/deploy-stack.ts | 15 ++++- .../test/api/deployments/deploy-stack.test.ts | 35 ++++++++++++ packages/aws-cdk/lib/cli/cli-config.ts | 1 + .../aws-cdk/lib/cli/cli-type-registry.json | 5 ++ packages/aws-cdk/lib/cli/cli.ts | 3 + .../aws-cdk/lib/cli/convert-to-user-input.ts | 2 + .../lib/cli/parse-command-line-arguments.ts | 5 ++ packages/aws-cdk/lib/cli/user-input.ts | 7 +++ .../aws-cdk/test/cli/cli-arguments.test.ts | 1 + 14 files changed, 173 insertions(+), 68 deletions(-) create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-deploy-with-revert-drift.integtest.ts create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/drift_helpers.ts diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-deploy-with-revert-drift.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-deploy-with-revert-drift.integtest.ts new file mode 100644 index 000000000..abd4a17db --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/deploy/cdk-deploy-with-revert-drift.integtest.ts @@ -0,0 +1,55 @@ +import { DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation'; +import { UpdateFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; +import { integTest, withDefaultFixture } from '../../../lib'; +import { waitForLambdaUpdateComplete } from '../drift/drift_helpers'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'deploy with revert-drift true', + withDefaultFixture(async (fixture) => { + await fixture.cdkDeploy('driftable', {}); + + // Get the Lambda, we want to now make it drift + const response = await fixture.aws.cloudFormation.send( + new DescribeStackResourcesCommand({ + StackName: fixture.fullStackName('driftable'), + }), + ); + const lambdaResource = response.StackResources?.find( + resource => resource.ResourceType === 'AWS::Lambda::Function', + ); + if (!lambdaResource || !lambdaResource.PhysicalResourceId) { + throw new Error('Could not find Lambda function in stack resources'); + } + const functionName = lambdaResource.PhysicalResourceId; + + // Update the Lambda function, introducing drift + await fixture.aws.lambda.send( + new UpdateFunctionConfigurationCommand({ + FunctionName: functionName, + Description: 'I\'m slowly drifting (drifting away)', + }), + ); + + // Wait for the stack update to complete + await waitForLambdaUpdateComplete(fixture, functionName); + + const drifted = await fixture.cdk(['drift', fixture.fullStackName('driftable')], { verbose: false }); + + expect(drifted).toMatch(/Stack.*driftable/); + expect(drifted).toContain('1 resource has drifted'); + + // Update the Stack with drift-aware + await fixture.cdkDeploy('driftable', { + options: ['--revert-drift'], + captureStderr: false, + }); + + // After performing a drift-aware deployment, verify that no drift has occurred. + const noDrifted = await fixture.cdk(['drift', fixture.fullStackName('driftable')], { verbose: false }); + + expect(noDrifted).toMatch(/Stack.*driftable/); // can't just .toContain because of formatting + expect(noDrifted).toContain('No drift detected'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---fail-throws-when-drift-is-detected.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---fail-throws-when-drift-is-detected.integtest.ts index 68ad5a4bc..afd50c862 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---fail-throws-when-drift-is-detected.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---fail-throws-when-drift-is-detected.integtest.ts @@ -1,6 +1,7 @@ import { DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation'; -import { GetFunctionCommand, UpdateFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; -import { integTest, sleep, withDefaultFixture } from '../../../lib'; +import { UpdateFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; +import { waitForLambdaUpdateComplete } from './drift_helpers'; +import { integTest, withDefaultFixture } from '../../../lib'; jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime @@ -44,34 +45,3 @@ integTest( ).rejects.toThrow('exited with error'); }), ); - -async function waitForLambdaUpdateComplete(fixture: any, functionName: string): Promise { - const delaySeconds = 5; - const timeout = 30_000; // timeout after 30s - const deadline = Date.now() + timeout; - - while (true) { - const response = await fixture.aws.lambda.send( - new GetFunctionCommand({ - FunctionName: functionName, - }), - ); - - const lastUpdateStatus = response.Configuration?.LastUpdateStatus; - - if (lastUpdateStatus === 'Successful') { - return; // Update completed successfully - } - - if (lastUpdateStatus === 'Failed') { - throw new Error('Lambda function update failed'); - } - - if (Date.now() > deadline) { - throw new Error(`Timed out after ${timeout / 1000} seconds.`); - } - - // Wait before checking again - await sleep(delaySeconds * 1000); - } -} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift.integtest.ts index edfc8384b..be38f5034 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift.integtest.ts @@ -1,6 +1,7 @@ import { DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation'; -import { GetFunctionCommand, UpdateFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; -import { integTest, sleep, withDefaultFixture } from '../../../lib'; +import { UpdateFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; +import { waitForLambdaUpdateComplete } from './drift_helpers'; +import { integTest, withDefaultFixture } from '../../../lib'; jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime @@ -63,34 +64,3 @@ integTest( } }), ); - -async function waitForLambdaUpdateComplete(fixture: any, functionName: string): Promise { - const delaySeconds = 5; - const timeout = 30_000; // timeout after 30s - const deadline = Date.now() + timeout; - - while (true) { - const response = await fixture.aws.lambda.send( - new GetFunctionCommand({ - FunctionName: functionName, - }), - ); - - const lastUpdateStatus = response.Configuration?.LastUpdateStatus; - - if (lastUpdateStatus === 'Successful') { - return; // Update completed successfully - } - - if (lastUpdateStatus === 'Failed') { - throw new Error('Lambda function update failed'); - } - - if (Date.now() > deadline) { - throw new Error(`Timed out after ${timeout / 1000} seconds.`); - } - - // Wait before checking again - await sleep(delaySeconds * 1000); - } -} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/drift_helpers.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/drift_helpers.ts new file mode 100644 index 000000000..8bc414341 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/drift_helpers.ts @@ -0,0 +1,33 @@ +import { GetFunctionCommand } from '@aws-sdk/client-lambda'; +import { sleep } from '../../../lib'; + +export async function waitForLambdaUpdateComplete(fixture: any, functionName: string): Promise { + const delaySeconds = 5; + const timeout = 30_000; // timeout after 30s + const deadline = Date.now() + timeout; + + while (true) { + const response = await fixture.aws.lambda.send( + new GetFunctionCommand({ + FunctionName: functionName, + }), + ); + + const lastUpdateStatus = response.Configuration?.LastUpdateStatus; + + if (lastUpdateStatus === 'Successful') { + return; // Update completed successfully + } + + if (lastUpdateStatus === 'Failed') { + throw new Error('Lambda function update failed'); + } + + if (Date.now() > deadline) { + throw new Error(`Timed out after ${timeout / 1000} seconds.`); + } + + // Wait before checking again + await sleep(delaySeconds * 1000); + } +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts index 60a795cc0..87adcc09d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts @@ -35,6 +35,13 @@ export interface ChangeSetDeployment { * @default false */ readonly importExistingResources?: boolean; + + /** + * Creates a drift-aware change set that brings actual resource states in line with template definitions. + * + * @default false + */ + readonly revertDrift?: boolean; } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts index ed6b4309d..833246889 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts @@ -432,7 +432,8 @@ 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 revertDrift = deploymentMethod.revertDrift ?? false; + const changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources, revertDrift); await this.updateTerminationProtection(); if (changeSetHasNoChanges(changeSetDescription)) { @@ -495,7 +496,7 @@ class FullCloudFormationDeployment { return this.executeChangeSet(changeSetDescription); } - private async createChangeSet(changeSetName: string, willExecute: boolean, importExistingResources: boolean) { + private async createChangeSet(changeSetName: string, willExecute: boolean, importExistingResources: boolean, revertDrift: boolean) { await this.cleanupOldChangeset(changeSetName); await this.ioHelper.defaults.debug(`Attempting to create ChangeSet with name ${changeSetName} to ${this.verb} stack ${this.stackName}`); @@ -508,6 +509,7 @@ class FullCloudFormationDeployment { Description: `CDK Changeset for execution ${this.uuid}`, ClientToken: `create${this.uuid}`, ImportExistingResources: importExistingResources, + DeploymentMode: revertDrift ? 'REVERT_DRIFT' : undefined, ...this.commonPrepareOptions(), }); @@ -774,6 +776,15 @@ async function canSkipDeploy( return false; } + // Drift-aware + if ( + deployStackOptions.deploymentMethod?.method === 'change-set' && + deployStackOptions.deploymentMethod.revertDrift + ) { + await ioHelper.defaults.debug(`${deployName}: --revert-drift, always creating change set`); + return false; + } + // No existing stack if (!cloudFormationStack.exists) { await ioHelper.defaults.debug(`${deployName}: no existing stack`); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts index 0160045cc..0202d2e29 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts @@ -1189,6 +1189,41 @@ describe('import-existing-resources', () => { }); }); +describe('revert-drift', () => { + test('is disabled by default', async () => { + // WHEN + await testDeployStack({ + ...standardDeployStackArguments(), + deploymentMethod: { + method: 'change-set', + }, + }); + + // THEN + expect(mockCloudFormationClient).toHaveReceivedCommandWith(CreateChangeSetCommand, { + ...expect.anything, + DeploymentMode: undefined, + }); + }); + + test('is added to the CreateChangeSetCommandInput', async () => { + // WHEN + await testDeployStack({ + ...standardDeployStackArguments(), + deploymentMethod: { + method: 'change-set', + revertDrift: true, + }, + }); + + // THEN + expect(mockCloudFormationClient).toHaveReceivedCommandWith(CreateChangeSetCommand, { + ...expect.anything, + DeploymentMode: 'REVERT_DRIFT', + }); + }); +}); + test.each([ // From a failed state, a --no-rollback is possible as long as there is not a replacement [StackStatus.UPDATE_FAILED, 'no-rollback', 'no-replacement', 'did-deploy-stack'], diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 51eb6c987..66e42654e 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -212,6 +212,7 @@ export async function makeConfig(): Promise { 'asset-parallelism': { type: 'boolean', desc: 'Whether to build/publish assets in parallel' }, 'asset-prebuild': { type: 'boolean', desc: 'Whether to build all assets before deploying the first stack (useful for failing Docker builds)', default: true }, 'ignore-no-stacks': { type: 'boolean', desc: 'Whether to deploy if the app contains no stacks', default: false }, + 'revert-drift': { type: 'boolean', desc: 'Create a drift-aware change set that brings actual resource states in line with template definitions', default: false } }, arg: { name: 'STACKS', diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index d854d5460..f0292597e 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -561,6 +561,11 @@ "type": "boolean", "desc": "Whether to deploy if the app contains no stacks", "default": false + }, + "revert-drift": { + "type": "boolean", + "desc": "Create a drift-aware change set that brings actual resource states in line with template definitions", + "default": false } }, "arg": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index a0775adb0..a76c8fd9e 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -651,6 +651,7 @@ function determineDeploymentMethod(args: any, configuration: Configuration, watc execute: true, changeSetName: args.changeSetName, importExistingResources: args.importExistingResources, + revertDrift: args.revertDrift, }; break; case 'prepare-change-set': @@ -659,6 +660,7 @@ function determineDeploymentMethod(args: any, configuration: Configuration, watc execute: false, changeSetName: args.changeSetName, importExistingResources: args.importExistingResources, + revertDrift: args.revertDrift, }; break; case undefined: @@ -668,6 +670,7 @@ function determineDeploymentMethod(args: any, configuration: Configuration, watc execute: watch ? true : args.execute ?? true, changeSetName: args.changeSetName, importExistingResources: args.importExistingResources, + revertDrift: args.revertDrift, }; break; } diff --git a/packages/aws-cdk/lib/cli/convert-to-user-input.ts b/packages/aws-cdk/lib/cli/convert-to-user-input.ts index 1e6fe841f..aadeb6ee6 100644 --- a/packages/aws-cdk/lib/cli/convert-to-user-input.ts +++ b/packages/aws-cdk/lib/cli/convert-to-user-input.ts @@ -143,6 +143,7 @@ export function convertYargsToUserInput(args: any): UserInput { assetParallelism: args.assetParallelism, assetPrebuild: args.assetPrebuild, ignoreNoStacks: args.ignoreNoStacks, + revertDrift: args.revertDrift, STACKS: args.STACKS, }; break; @@ -431,6 +432,7 @@ export function convertConfigToUserInput(config: any): UserInput { assetParallelism: config.deploy?.assetParallelism, assetPrebuild: config.deploy?.assetPrebuild, ignoreNoStacks: config.deploy?.ignoreNoStacks, + revertDrift: config.deploy?.revertDrift, }; const rollbackOptions = { all: config.rollback?.all, diff --git a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts index bf6ebccca..1170f561e 100644 --- a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts +++ b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts @@ -598,6 +598,11 @@ export function parseCommandLineArguments(args: Array): any { default: false, type: 'boolean', desc: 'Whether to deploy if the app contains no stacks', + }) + .option('revert-drift', { + default: false, + type: 'boolean', + desc: 'Create a drift-aware change set that brings actual resource states in line with template definitions', }), ) .command('rollback [STACKS..]', 'Rolls back the stack(s) named STACKS to their last stable state', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 1cf678f6a..97d0ac2ac 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -932,6 +932,13 @@ export interface DeployOptions { */ readonly ignoreNoStacks?: boolean; + /** + * Create a drift-aware change set that brings actual resource states in line with template definitions + * + * @default - false + */ + readonly revertDrift?: boolean; + /** * Positional argument for deploy */ diff --git a/packages/aws-cdk/test/cli/cli-arguments.test.ts b/packages/aws-cdk/test/cli/cli-arguments.test.ts index 839f2719a..1241405b7 100644 --- a/packages/aws-cdk/test/cli/cli-arguments.test.ts +++ b/packages/aws-cdk/test/cli/cli-arguments.test.ts @@ -60,6 +60,7 @@ describe('yargs', () => { previousParameters: true, progress: undefined, requireApproval: undefined, + revertDrift: undefined, rollback: false, tags: undefined, toolkitStackName: undefined, From 01269f8378850530a00e82ffdc7de0a094f3a11d Mon Sep 17 00:00:00 2001 From: aki-kii Date: Sun, 8 Feb 2026 22:30:03 +0900 Subject: [PATCH 2/2] chore: add validation --- packages/aws-cdk/lib/cli/cli.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index a76c8fd9e..07fc8bdad 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -643,6 +643,9 @@ function determineDeploymentMethod(args: any, configuration: Configuration, watc if (args.importExistingResources) { throw new ToolkitError('--import-existing-resources cannot be enabled with method=direct'); } + if (args.revertDrift) { + throw new ToolkitError('--revert-drift cannot be used with method=direct'); + } deploymentMethod = { method: 'direct' }; break; case 'change-set':