diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index c0c7642e4..a6f916dfa 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -1207,6 +1207,10 @@ switch (stackSet) { case 'stage-with-no-stacks': break; + case 'stage-only': + new SomeStage(app, `${stackPrefix}-stage`); + break; + default: throw new Error(`Unrecognized INTEG_STACK_SET: '${stackSet}'`); } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts new file mode 100644 index 000000000..429e6c5be --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts @@ -0,0 +1,21 @@ +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy does not fail even if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2])).resolves.not.toThrow(); +})); + +integTest('cdk destroy with no force option exits without prompt if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2], { + force: false, + })).resolves.not.toThrow(); +})); + +integTest('cdk destroy does not fail even if the stages do not exist', withDefaultFixture(async (fixture) => { + await expect(fixture.cdkDestroy('NonExistent/*')).resolves.not.toThrow(); +})); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts new file mode 100644 index 000000000..95a7d1c50 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts @@ -0,0 +1,28 @@ +import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy can destroy stacks in stage-only configuration', withDefaultFixture(async (fixture) => { + const integStackSet = 'stage-only'; + + const stageNameSuffix = 'stage'; + const specifiedStackName = `${stageNameSuffix}/*`; + + await fixture.cdkDeploy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + const stackName = `${fixture.fullStackName(stageNameSuffix)}-StackInStage`; + const stack = await fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName })); + expect(stack.Stacks?.length ?? 0).toEqual(1); + + await fixture.cdkDestroy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + await expect(fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName }))) + .rejects.toThrow(/does not exist/); +})); diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 7be969552..292e60b97 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -129,6 +129,8 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I7010` | Confirm destroy stacks | `info` | {@link ConfirmationRequest} | | `CDK_TOOLKIT_I7100` | Stack destroy progress | `info` | {@link StackDestroyProgress} | | `CDK_TOOLKIT_I7101` | Start stack destroying | `trace` | {@link StackDestroy} | +| `CDK_TOOLKIT_W7010` | A provided stack name does not match any stack | `warn` | n/a | +| `CDK_TOOLKIT_W7011` | No stacks match the provided names, nothing to destroy | `warn` | n/a | | `CDK_TOOLKIT_I7900` | Stack deletion succeeded | `result` | [cxapi.CloudFormationStackArtifact](https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_cx-api.CloudFormationStackArtifact.html) | | `CDK_TOOLKIT_W7902` | Express Mode deletion completed with resources still tearing down | `warn` | n/a | | `CDK_TOOLKIT_E7010` | Action was aborted due to negative confirmation of request | `error` | n/a | diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 02b953c7a..415ad977e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -398,6 +398,15 @@ export const IO = { interface: 'StackDestroy', }), + CDK_TOOLKIT_W7010: make.warn({ + code: 'CDK_TOOLKIT_W7010', + description: 'A provided stack name does not match any stack', + }), + CDK_TOOLKIT_W7011: make.warn({ + code: 'CDK_TOOLKIT_W7011', + description: 'No stacks match the provided names, nothing to destroy', + }), + CDK_TOOLKIT_I7900: make.result({ code: 'CDK_TOOLKIT_I7900', description: 'Stack deletion succeeded', diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 93c63bf84..605d9586d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -24,6 +24,7 @@ function isFileEvent(event: EventName): event is FileEvent { return (FILE_EVENTS as readonly string[]).includes(event); } import * as fs from 'fs-extra'; +import * as picomatch from 'picomatch'; import { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; @@ -1621,12 +1622,21 @@ export class Toolkit extends CloudAssemblySourceBuilder { const ioHelper = asIoHelper(this.ioHost, action); const stacks = await assembly.selectStacksV2(selectStacks); + await this.suggestStacks(ioHelper, assembly, selectStacks, stacks); + const ret: DestroyResult = { stacks: [], }; + if (stacks.stackCount === 0) { + await ioHelper.notify(IO.CDK_TOOLKIT_W7011.msg( + `No stacks match the name(s): ${chalk.red((selectStacks.patterns ?? []).join(', '))}`, + )); + return ret; + } + const motivation = 'Destroying stacks is an irreversible action'; - const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; + const question = `Are you sure you want to delete: ${chalk.blue(stacks.hierarchicalIds.join(', '))}`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); if (!confirmed) { await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); @@ -1694,6 +1704,39 @@ export class Toolkit extends CloudAssemblySourceBuilder { } } + /** + * Warn about destroy patterns that matched no stack, suggesting a close match + * when one exists (e.g. only the casing differs). Stacks nested inside a stage + * are considered, so a staged stack can be suggested too. + */ + private async suggestStacks( + ioHelper: IoHelper, + assembly: StackAssembly, + selector: StackSelector, + selected: StackCollection, + ): Promise { + const patterns = selector.patterns ?? []; + if (patterns.length === 0) { + return; + } + + const allStacks = await assembly.selectStacksV2(ALL_STACKS); + + for (const pattern of patterns) { + const matched = selected.stackArtifacts.some((stack) => picomatch.isMatch(stack.hierarchicalId, pattern)); + if (matched) { + continue; + } + + const closeMatches = allStacks.stackArtifacts + .filter((stack) => picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) + .map((stack) => stack.hierarchicalId); + + const suggestion = closeMatches.length > 0 ? ` Do you mean ${chalk.blue(closeMatches.join(', '))}?` : ''; + await ioHelper.notify(IO.CDK_TOOLKIT_W7010.msg(`${chalk.red(pattern)} does not exist.${suggestion}`)); + } + } + /** * Create a deployments class */ diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts new file mode 100644 index 000000000..51219a694 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app with a top-level stack plus a stack nested inside a Stage. + * + * Hierarchical ids: `TopLevelStack` and `Stage/StackInStage`. Used to exercise + * destroy/suggestion behavior across both top-level and nested-stage stacks. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + new core.Stack(app, 'TopLevelStack'); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts new file mode 100644 index 000000000..ed0835628 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app whose only stack lives inside a Stage (no top-level stacks). + * + * This is the configuration that regressed the original `cdk destroy` warning + * feature: code that only looked at top-level stacks could not see (or suggest) + * stacks nested in a Stage. The stack's hierarchical id is `Stage/StackInStage`. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index 88b8cf2d6..eaae88baa 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -40,7 +40,7 @@ describe('destroy', () => { action: 'destroy', level: 'info', code: 'CDK_TOOLKIT_I7010', - message: expect.stringContaining('Are you sure you want to delete'), + message: expect.stringContaining(`Are you sure you want to delete: ${chalk.blue('Stack1')}`), })); }); @@ -177,6 +177,199 @@ describe('destroy', () => { expect(mockDispose).toHaveBeenCalled(); await realDispose(); }); + + test('warns when no stacks match the given name(s)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a closely matching stack when the name does not exist', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stack1'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining('does not exist. Do you mean'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('warns about a missing name but still destroys the matching stacks', async () => { + // WHEN: one name matches (Stack1), the other does not + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stack1', 'DoesNotExist'] }, + }); + + // THEN: warn for the missing name, but no "no stacks match" and the match is destroyed + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('DoesNotExist')} does not exist.`), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys all top-level stacks with the MAIN_ASSEMBLY strategy (cdk destroy --all)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.MAIN_ASSEMBLY }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(2); + }); + + test('destroys the single stack with the ONLY_SINGLE strategy (cdk destroy with no patterns)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.ONLY_SINGLE }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('warns about every non-existent name when none of them match', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['NopeA', 'NopeB'] }, + }); + + // THEN: a per-name warning for each, plus the overall no-match warning + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeA')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeB')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + describe('stacks nested in a stage', () => { + test('destroys a stack inside a stage by its hierarchical id', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + }); + + // THEN: only the staged stack is destroyed, not the top-level one + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a staged stack via a wildcard pattern', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a stack in a stage-only app (no top-level stacks)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys a staged stack via wildcard in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('warns without failing for a non-existent name in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist/*'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a nested-stage stack when only the casing differs', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/stackinstage'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a nested-stage stack for a wildcard pattern that differs only in case', async () => { + // WHEN: a lower-cased wildcard that matches nothing but resembles the staged stack + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/*'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + }); }); function successfulDestroy() {