Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3d2c68c
refactor: filter stacks before getting their templates (#960)
otaviomacedo Nov 26, 2025
a1676c3
Merge branch 'main' of https://github.com/go-to-k/aws-cdk-cli into de…
go-to-k Jun 23, 2026
6041851
refactor: route cdk destroy through toolkit-lib
go-to-k Jun 24, 2026
ec87712
chore: regenerate message registry and destroy IoHost snapshots
go-to-k Jun 24, 2026
b2ff421
test: migrate destroy tests to toolkit-lib
go-to-k Jun 24, 2026
102d357
chore(toolkit-lib): fix picomatch import order (eslint import/order)
go-to-k Jun 24, 2026
ac99b78
fix(cli): make destroy IoHost snapshots color-independent
go-to-k Jun 24, 2026
4626130
fix(toolkit-lib): keep destroy confirmation stack names blue
go-to-k Jun 24, 2026
d442ab3
Merge branch 'main' into destroy
go-to-k Jun 25, 2026
9c1083c
chore: self mutation
github-actions[bot] Jun 25, 2026
0cca283
Merge branch 'main' of https://github.com/go-to-k/aws-cdk-cli into de…
go-to-k Jun 30, 2026
0747ba5
feat(toolkit-lib): destroy warns and suggests when stack names do not…
go-to-k Jun 30, 2026
d666187
fix(toolkit-lib): keep destroy confirmation stack names blue
go-to-k Jun 30, 2026
484c5ca
test(toolkit-lib): drop redundant comment in destroy confirmation col…
go-to-k Jun 30, 2026
6a96b5b
Merge branch 'main' into destroy
go-to-k Jul 2, 2026
532e323
Merge branch 'main' into destroy
go-to-k Jul 3, 2026
653c0e6
Merge branch 'main' into destroy
go-to-k Jul 9, 2026
a826116
Merge branch 'main' into destroy
go-to-k Aug 11, 2026
fa032d1
Merge branch 'main' into destroy
go-to-k Aug 20, 2026
50c4d94
Merge branch 'main' into destroy
go-to-k Aug 21, 2026
b93a4b5
refactor(toolkit-lib): move destroy match suggestions into StackAssem…
go-to-k Aug 24, 2026
739000f
small refactor
mrgrain Aug 24, 2026
0ceda07
Merge branch 'main' into destroy
mrgrain Aug 24, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,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}'`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}));
Original file line number Diff line number Diff line change
@@ -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/);
}));
2 changes: 2 additions & 0 deletions packages/@aws-cdk/toolkit-lib/docs/message-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import '../../../private/dispose-polyfill';
import type { CloudFormationStackArtifact } from '@aws-cdk/cloud-assembly-api';
import { isMatch as picomatch } from 'picomatch';
import { major } from 'semver';
import { ToolkitError } from '../../../toolkit/toolkit-error';
import type { IoHelper } from '../../io/private';
Expand All @@ -8,10 +10,44 @@ import type { StackSelector } from '../stack-selector';
import { ExpandStackSelection, StackSelectionStrategy } from '../stack-selector';
import type { IReadableCloudAssembly } from '../types';

/**
* Options for `StackAssembly.selectStacksV3`.
*/
export interface SelectStacksV3Options {
/**
* For a pattern selector, also compute suggestions for every pattern that
* matched no stack (see `SelectStacksV3Result.suggestions`).
*
* @default false
*/
readonly suggestPatternMatches?: boolean;
}

/**
* Result of `StackAssembly.selectStacksV3`.
*/
export interface SelectStacksV3Result {
/**
* The selected stacks.
*/
readonly stacks: StackCollection;

/**
* Only present when `suggestPatternMatches` was requested and the selector is
* a pattern selector: for every provided pattern that matched no stack, the
* hierarchical ids of stacks that loosely (case-insensitively) resemble it.
* The array is empty when there is no close match. Patterns that matched at
* least one stack do not appear.
*/
readonly suggestions?: Record<string, string[]>;
}

/**
* A single Cloud Assembly wrapped to provide additional stack operations.
*/
export class StackAssembly extends BaseStackAssembly implements IReadableCloudAssembly {
private _allStacks: CloudFormationStackArtifact[] | undefined;

constructor(private readonly _asm: IReadableCloudAssembly, ioHelper: IoHelper) {
super(_asm.cloudAssembly, ioHelper);
}
Expand All @@ -32,15 +68,39 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs
return this.dispose();
}

/**
* Cached get the fetch all CloudFormationStackArtifacts for the assembly.
*/
private get allStacks(): CloudFormationStackArtifact[] {
if (!this._allStacks) {
this._allStacks = major(this.assembly.version) < 10 ? this.assembly.stacks : this.assembly.stacksRecursively;
}

return this._allStacks;
}

/**
* Improved stack selection interface with a single selector
* @throws when the assembly does not contain any stacks, unless `selector.failOnEmpty` is `false`
* @throws when individual selection strategies are not satisfied
*
* Thin wrapper around `selectStacksV3` that keeps the historic return shape.
*/
public async selectStacksV2(selector: StackSelector): Promise<StackCollection> {
return (await this.selectStacksV3(selector)).stacks;
}

/**
* Improved stack selection interface with a single selector, optionally
* reporting suggestions for patterns that matched no stack.
*
* @throws when the assembly does not contain any stacks, unless `selector.failOnEmpty` is `false`
* @throws when individual selection strategies are not satisfied
*/
public async selectStacksV3(selector: StackSelector, options: SelectStacksV3Options = {}): Promise<SelectStacksV3Result> {
const asm = this.assembly;
const topLevelStacks = asm.stacks;
const allStacks = major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively;
const allStacks = this.allStacks;

if (allStacks.length === 0 && (selector.failOnEmpty ?? true)) {
throw new ToolkitError('NoStacksInApp', 'This app contains no stacks');
Expand All @@ -51,20 +111,20 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs

switch (selector.strategy) {
case StackSelectionStrategy.ALL_STACKS:
return new StackCollection(this, allStacks);
return { stacks: new StackCollection(this, allStacks) };
case StackSelectionStrategy.MAIN_ASSEMBLY:
if (topLevelStacks.length < 1) {
// @todo text should probably be handled in io host
throw new ToolkitError('NoStackInMainAssembly', 'No stack found in the main cloud assembly. Use "list" to print manifest');
}
return this.extendStacks(topLevelStacks, allStacks, extend);
return { stacks: await this.extendStacks(topLevelStacks, allStacks, extend) };
case StackSelectionStrategy.ONLY_SINGLE:
if (topLevelStacks.length !== 1) {
// @todo text should probably be handled in io host
throw new ToolkitError('MultipleStacksWithoutSelector', 'Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' +
`Stacks: ${allStacks.map(x => x.hierarchicalId).join(' · ')}`);
}
return new StackCollection(this, topLevelStacks);
return { stacks: new StackCollection(this, topLevelStacks) };
default:
const matched = await this.selectMatchingStacks(allStacks, patterns, extend);
if (
Expand All @@ -88,20 +148,30 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs
);
}

return matched;
return {
stacks: matched,
suggestions: options.suggestPatternMatches ? this.suggestionsForPatterns(patterns, matched) : undefined,
};
}
}

/**
* Select all stacks.
*
* This method never throws and can safely be used as a basis for other calculations.
*
* @returns a `StackCollection` of all stacks
* For every pattern that matched no stack, collect the hierarchical ids of
* stacks that loosely (case-insensitively) resemble it. Patterns that matched
* at least one stack are omitted; the array is empty when there is no close
* match. Pure computation, never throws, emits no output.
*/
public selectAllStacks() {
const allStacks = major(this.assembly.version) < 10 ? this.assembly.stacks : this.assembly.stacksRecursively;
return new StackCollection(this, allStacks);
private suggestionsForPatterns(patterns: string[], matched: StackCollection): Record<string, string[]> {
const suggestions: Record<string, string[]> = {};
for (const pattern of patterns) {
if (matched.stackArtifacts.some((stack) => picomatch(stack.hierarchicalId, pattern))) {
continue;
}
suggestions[pattern] = this.allStacks
.filter((stack) => picomatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase()))
.map((stack) => stack.hierarchicalId);
}
return suggestions;
}

/**
Expand All @@ -110,8 +180,8 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs
* @returns a `StackCollection` of all stacks that needs to be validated
*/
public selectStacksForValidation() {
const allStacks = this.selectAllStacks();
return allStacks.filter((art) => art.validateOnSynth ?? false);
const selected = this.allStacks.filter((art) => art.validateOnSynth ?? false);
return new StackCollection(this, selected);
}
}

Expand Down
9 changes: 9 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Comment on lines +401 to +408

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Like said elsewhere, I think a single message will be enough but it should carry a payload of the unmatched selector and idenfified matches.


CDK_TOOLKIT_I7900: make.result<cxapi.CloudFormationStackArtifact>({
code: 'CDK_TOOLKIT_I7900',
description: 'Stack deletion succeeded',
Expand Down
18 changes: 16 additions & 2 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1772,14 +1772,28 @@ export class Toolkit extends CloudAssemblySourceBuilder {
private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise<DestroyResult> {
const selectStacks = stacksOpt(options);
const ioHelper = asIoHelper(this.ioHost, action);
const stacks = await assembly.selectStacksV2(selectStacks);
const { stacks, suggestions } = await assembly.selectStacksV3(selectStacks, { suggestPatternMatches: true });

// Warn about each provided pattern that matched no stack, suggesting a close
// match when one exists (e.g. only the casing differs).
for (const [pattern, closeMatches] of Object.entries(suggestions ?? {})) {
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}`));
}

const ret: DestroyResult = {
stacks: [],
};

if (stacks.stackCount === 0) {
await ioHelper.notify(IO.CDK_TOOLKIT_W7011.msg(
Comment thread
mrgrain marked this conversation as resolved.
`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'));
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
};
16 changes: 16 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts
Original file line number Diff line number Diff line change
@@ -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();
};
Loading
Loading