From 6b117a46458a9f65dde5b03baf50ec482b1dff41 Mon Sep 17 00:00:00 2001 From: Momo Kornher Date: Mon, 21 Sep 2026 14:43:54 +0100 Subject: [PATCH] feat(cli): support --change-set-name for cdk diff Deploy, watch, and import already accept --change-set-name, but diff always creates its change set with the hardcoded name 'cdk-diff-change-set'. Add a changeSetName option to toolkit-lib's ChangeSetDiffOptions and plumb it through to createDiffChangeSet, keeping the existing name as the default. Expose it as --change-set-name on cdk diff, rejected when combined with --method=template (consistent with deploy rejecting it for --method=direct). --- .../toolkit-lib/lib/actions/diff/index.ts | 7 +++ .../lib/actions/diff/private/helpers.ts | 1 + .../lib/api/deployments/cfn-api.ts | 10 +++- .../toolkit-lib/test/actions/diff.test.ts | 26 +++++++++++ packages/aws-cdk/lib/cli/cdk-toolkit.ts | 10 ++++ packages/aws-cdk/lib/cli/cli-config.ts | 1 + .../aws-cdk/lib/cli/cli-type-registry.json | 4 ++ packages/aws-cdk/lib/cli/cli.ts | 9 +++- .../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/diff-options.test.ts | 46 ++++++++----------- packages/aws-cdk/test/commands/diff.test.ts | 33 +++++++++++++ 13 files changed, 132 insertions(+), 29 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts index 9e200d645..c76a709ff 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts @@ -33,6 +33,13 @@ export interface ChangeSetDiffOptions extends CloudFormationDiffOptions { * @default false */ readonly importExistingResources?: boolean; + + /** + * Name of the CloudFormation change set to create when computing the diff + * + * @default 'cdk-diff-change-set' + */ + readonly changeSetName?: string; } export interface LocalFileDiffOptions { diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/diff/private/helpers.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/diff/private/helpers.ts index 3281f3803..1cdc164fe 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/diff/private/helpers.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/diff/private/helpers.ts @@ -99,6 +99,7 @@ async function cfnDiff( parameters: methodOptions.parameters ?? {}, failOnError: !(methodOptions.fallbackToTemplate ?? true), importExistingResources: methodOptions.importExistingResources, + changeSetName: methodOptions.changeSetName, uuid: randomUUID(), }))?.changeSet : undefined; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts index b189162d2..22f69dc28 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts @@ -42,6 +42,12 @@ export type PrepareChangeSetOptions = { resourcesToImport?: ResourcesToImport; importExistingResources?: boolean; includeNestedStacks?: boolean; + /** + * Name of the change set to create + * + * @default 'cdk-diff-change-set' + */ + changeSetName?: string; /** * Default behavior is to log AWS CloudFormation errors and move on. Set this property to true to instead * fail on errors received by AWS CloudFormation. @@ -82,7 +88,7 @@ export async function createDiffChangeSet( return await createChangeSetAndCleanup(ioHelper, { cfn, - changeSetName: 'cdk-diff-change-set', + changeSetName: options.changeSetName ?? 'cdk-diff-change-set', stack: options.stack, exists, uuid: options.uuid, @@ -279,7 +285,7 @@ async function createChangeSetAndCleanup( */ export async function createValidationChangeSet( ioHelper: IoHelper, - options: Omit, + options: Omit, ): Promise { const { cfn, bodyParameter, exists, stackExistedBefore, executionRoleArn, diagnoser } = await prepareChangeSetEnv(ioHelper, options); const changeSetName = `cdk-validate-${options.uuid}`; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts index c46a8511a..a530b56a3 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts @@ -511,6 +511,32 @@ describe('diff', () => { })); }); + test('ChangeSet diff method uses the provided change set name', async () => { + // GIVEN - stack doesn't exist + jest.spyOn(deployments.Deployments.prototype, 'stackExists').mockResolvedValue(false); + mockCloudFormationClient.on(DescribeStacksCommand).resolves({ Stacks: [] }); + mockSSMClient.on(GetParameterCommand).resolves({ Parameter: { Value: '99' } }); + mockCloudFormationClient.on(CreateChangeSetCommand).resolves({ Id: 'arn:aws:cloudformation:us-east-1:123456789012:changeSet/my-custom-change-set' }); + mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({ + Status: 'CREATE_COMPLETE', + Changes: [], + }); + + // WHEN + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + await toolkit.diff(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + method: DiffMethod.ChangeSet({ fallbackToTemplate: false, changeSetName: 'my-custom-change-set' }), + }); + + // THEN - the changeset was created with the custom name + const createCalls = mockCloudFormationClient.commandCalls(CreateChangeSetCommand); + expect(createCalls).toHaveLength(1); + expect(createCalls[0].args[0].input).toEqual(expect.objectContaining({ + ChangeSetName: 'my-custom-change-set', + })); + }); + test('ChangeSet diff deletes stack created in REVIEW_IN_PROGRESS for new stacks', async () => { // GIVEN - stack doesn't exist jest.spyOn(deployments.Deployments.prototype, 'stackExists').mockResolvedValue(false); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 7fc83daad..4115eacdb 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -447,6 +447,7 @@ export class CdkToolkit { parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]), resourcesToImport, importExistingResources: options.importExistingResources, + changeSetName: options.changeSetName, failOnError: options.method === 'change-set', }); } @@ -1645,6 +1646,15 @@ export interface DiffOptions { */ readonly method?: 'auto' | 'change-set' | 'template'; + /** + * Name of the CloudFormation change set to create when computing the diff + * + * Only used if the method is not 'template'. + * + * @default 'cdk-diff-change-set' + */ + readonly changeSetName?: string; + /** * Whether or not the change set imports resources that already exist. * diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index fdd54993c..81f2425b6 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -423,6 +423,7 @@ export async function makeConfig(): Promise { 'processed': { type: 'boolean', desc: 'Whether to compare against the template with Transforms already processed', default: false }, 'quiet': { type: 'boolean', alias: 'q', desc: 'Do not print stack name and default message when there is no diff to stdout', default: false }, 'change-set': { type: 'boolean', alias: 'changeset', desc: 'Whether to create a change set to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.', default: true, deprecated: 'use --method instead' }, + 'change-set-name': { type: 'string', desc: 'Name of the CloudFormation change set to create (only if method is not template)' }, 'method': { alias: 'm', type: 'string', diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 772c379de..9ca91fef2 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -942,6 +942,10 @@ "default": true, "deprecated": "use --method instead" }, + "change-set-name": { + "type": "string", + "desc": "Name of the CloudFormation change set to create (only if method is not template)" + }, "method": { "alias": "m", "type": "string", diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index b979ecc99..9a25ec092 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -346,6 +346,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { desc: 'Whether to create a change set to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.', deprecated: 'use --method instead', }) + .option('change-set-name', { + default: undefined, + type: 'string', + desc: 'Name of the CloudFormation change set to create (only if method is not template)', + }) .option('method', { default: 'auto', alias: 'm', diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 779c752b3..6c237558b 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -1493,6 +1493,13 @@ export interface DiffOptions { */ readonly changeSet?: boolean; + /** + * Name of the CloudFormation change set to create (only if method is not template) + * + * @default - undefined + */ + readonly changeSetName?: string; + /** * How to compute the diff. "auto" attempts to create a change set and falls back to template-only on failure. "change-set" creates a change set and fails if it cannot be created. Both use the deploy role instead of the lookup role. "template" compares templates directly and uses the lookup role. * diff --git a/packages/aws-cdk/test/cli/diff-options.test.ts b/packages/aws-cdk/test/cli/diff-options.test.ts index 554c06cc5..f42992e55 100644 --- a/packages/aws-cdk/test/cli/diff-options.test.ts +++ b/packages/aws-cdk/test/cli/diff-options.test.ts @@ -3,6 +3,7 @@ import { exec } from '../../lib/cli/cli'; // Prevent actual toolkit operations let diffSpy: jest.SpyInstance; + beforeEach(() => { diffSpy = jest.spyOn(cdkToolkitModule.CdkToolkit.prototype, 'diff').mockResolvedValue(0); }); @@ -11,39 +12,32 @@ afterEach(() => { jest.restoreAllMocks(); }); -describe('diff --method option', () => { - test('defaults to method=auto', async () => { - await exec(['diff', '--app', 'echo']); - expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'auto' })); - }); +describe('diff --change-set-name', () => { + test('passes through to CdkToolkit.diff', async () => { + await exec(['diff', '--app', 'echo', '--change-set-name=MyCS', 'MyStack']); - test('--method=change-set', async () => { - await exec(['diff', '--app', 'echo', '--method=change-set']); - expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'change-set' })); + expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ + changeSetName: 'MyCS', + })); }); - test('--method=template', async () => { - await exec(['diff', '--app', 'echo', '--method=template']); - expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'template' })); - }); - - test('deprecated --no-change-set maps to method=template', async () => { - await exec(['diff', '--app', 'echo', '--no-change-set']); - expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'template' })); - }); + test('defaults to undefined', async () => { + await exec(['diff', '--app', 'echo', 'MyStack']); - test('deprecated --change-set maps to method=auto', async () => { - await exec(['diff', '--app', 'echo', '--change-set']); - expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'auto' })); + expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ + changeSetName: undefined, + })); }); - test('--method takes precedence over deprecated --no-change-set', async () => { - await exec(['diff', '--app', 'echo', '--method=change-set', '--no-change-set']); - expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'change-set' })); + test('cannot be used with --method=template', async () => { + await expect( + exec(['diff', '--app', 'echo', '--method=template', '--change-set-name=MyCS', 'MyStack']), + ).rejects.toThrow('--change-set-name cannot be used with --method=template'); }); - test('--template implies method=template', async () => { - await exec(['diff', '--app', 'echo', '--template=/tmp/template.json']); - expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'template' })); + test('cannot be used with --no-change-set', async () => { + await expect( + exec(['diff', '--app', 'echo', '--no-change-set', '--change-set-name=MyCS', 'MyStack']), + ).rejects.toThrow('--change-set-name cannot be used with --method=template'); }); }); diff --git a/packages/aws-cdk/test/commands/diff.test.ts b/packages/aws-cdk/test/commands/diff.test.ts index c8b22b35d..bbcfa348e 100644 --- a/packages/aws-cdk/test/commands/diff.test.ts +++ b/packages/aws-cdk/test/commands/diff.test.ts @@ -292,6 +292,39 @@ Resources expect(exitCode).toBe(0); }); + test('passes the change set name to createDiffChangeSet', async () => { + createDiffChangeSet = jest.spyOn(cfnApi, 'createDiffChangeSet').mockImplementationOnce(async () => { + return { + changeSet: { + $metadata: {}, + Changes: [ + { + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'MyGlobalTable', + }, + }, + ], + }, + diagnosis: Diagnosis.noProblem(), + }; + }); + + // WHEN + const exitCode = await toolkit.diff({ + stackNames: ['A'], + method: 'auto', + changeSetName: 'my-custom-change-set', + }); + + // THEN + expect(createDiffChangeSet).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ changeSetName: 'my-custom-change-set' }), + ); + expect(exitCode).toBe(0); + }); + test('when invoked with local template path', async () => { const templatePath = 'oldTemplate.json'; const oldTemplate = {