Skip to content

Commit 48ad09e

Browse files
committed
feat(toolkit-lib): destroy warns and suggests when stack names do not match
Re-applies the destroy warning feature on top of main's toolkit-lib destroy migration (#1686): - W7011: emit a warning and exit without prompting when no stacks match - W7010: warn about each non-existent name (suggesting a close match when one exists, including stacks nested in a stage) while still destroying matches - adds suggestStacks() to Toolkit and the supporting message codes/registry - restores toolkit-lib unit tests, stage fixtures, and the destroy integ tests The toolkit-lib migration plumbing from this branch is dropped in favor of main's implementation; only the warning behavior is preserved.
1 parent 0cca283 commit 48ad09e

9 files changed

Lines changed: 336 additions & 0 deletions

File tree

packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,10 @@ switch (stackSet) {
12071207
case 'stage-with-no-stacks':
12081208
break;
12091209

1210+
case 'stage-only':
1211+
new SomeStage(app, `${stackPrefix}-stage`);
1212+
break;
1213+
12101214
default:
12111215
throw new Error(`Unrecognized INTEG_STACK_SET: '${stackSet}'`);
12121216
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { integTest, withDefaultFixture } from '../../../lib';
2+
3+
integTest('cdk destroy does not fail even if the stacks do not exist', withDefaultFixture(async (fixture) => {
4+
const nonExistingStackName1 = 'non-existing-stack-1';
5+
const nonExistingStackName2 = 'non-existing-stack-2';
6+
7+
await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2])).resolves.not.toThrow();
8+
}));
9+
10+
integTest('cdk destroy with no force option exits without prompt if the stacks do not exist', withDefaultFixture(async (fixture) => {
11+
const nonExistingStackName1 = 'non-existing-stack-1';
12+
const nonExistingStackName2 = 'non-existing-stack-2';
13+
14+
await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2], {
15+
force: false,
16+
})).resolves.not.toThrow();
17+
}));
18+
19+
integTest('cdk destroy does not fail even if the stages do not exist', withDefaultFixture(async (fixture) => {
20+
await expect(fixture.cdkDestroy('NonExistent/*')).resolves.not.toThrow();
21+
}));
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
2+
import { integTest, withDefaultFixture } from '../../../lib';
3+
4+
integTest('cdk destroy can destroy stacks in stage-only configuration', withDefaultFixture(async (fixture) => {
5+
const integStackSet = 'stage-only';
6+
7+
const stageNameSuffix = 'stage';
8+
const specifiedStackName = `${stageNameSuffix}/*`;
9+
10+
await fixture.cdkDeploy(specifiedStackName, {
11+
modEnv: {
12+
INTEG_STACK_SET: integStackSet,
13+
},
14+
});
15+
16+
const stackName = `${fixture.fullStackName(stageNameSuffix)}-StackInStage`;
17+
const stack = await fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName }));
18+
expect(stack.Stacks?.length ?? 0).toEqual(1);
19+
20+
await fixture.cdkDestroy(specifiedStackName, {
21+
modEnv: {
22+
INTEG_STACK_SET: integStackSet,
23+
},
24+
});
25+
26+
await expect(fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName })))
27+
.rejects.toThrow(/does not exist/);
28+
}));

packages/@aws-cdk/toolkit-lib/docs/message-registry.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu
128128
| `CDK_TOOLKIT_I7010` | Confirm destroy stacks | `info` | {@link ConfirmationRequest} |
129129
| `CDK_TOOLKIT_I7100` | Stack destroy progress | `info` | {@link StackDestroyProgress} |
130130
| `CDK_TOOLKIT_I7101` | Start stack destroying | `trace` | {@link StackDestroy} |
131+
| `CDK_TOOLKIT_W7010` | A provided stack name does not match any stack | `warn` | n/a |
132+
| `CDK_TOOLKIT_W7011` | No stacks match the provided names, nothing to destroy | `warn` | n/a |
131133
| `CDK_TOOLKIT_I7900` | Stack deletion succeeded | `result` | [cxapi.CloudFormationStackArtifact](https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_cx-api.CloudFormationStackArtifact.html) |
132134
| `CDK_TOOLKIT_E7010` | Action was aborted due to negative confirmation of request | `error` | n/a |
133135
| `CDK_TOOLKIT_E7900` | Stack deletion failed | `error` | {@link ErrorPayload} |

packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,15 @@ export const IO = {
394394
interface: 'StackDestroy',
395395
}),
396396

397+
CDK_TOOLKIT_W7010: make.warn({
398+
code: 'CDK_TOOLKIT_W7010',
399+
description: 'A provided stack name does not match any stack',
400+
}),
401+
CDK_TOOLKIT_W7011: make.warn({
402+
code: 'CDK_TOOLKIT_W7011',
403+
description: 'No stacks match the provided names, nothing to destroy',
404+
}),
405+
397406
CDK_TOOLKIT_I7900: make.result<cxapi.CloudFormationStackArtifact>({
398407
code: 'CDK_TOOLKIT_I7900',
399408
description: 'Stack deletion succeeded',

packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function isFileEvent(event: EventName): event is FileEvent {
2424
return (FILE_EVENTS as readonly string[]).includes(event);
2525
}
2626
import * as fs from 'fs-extra';
27+
import * as picomatch from 'picomatch';
2728
import { NonInteractiveIoHost } from './non-interactive-io-host';
2829
import type { ToolkitServices } from './private';
2930
import { assemblyFromSource } from './private';
@@ -1604,10 +1605,19 @@ export class Toolkit extends CloudAssemblySourceBuilder {
16041605
const ioHelper = asIoHelper(this.ioHost, action);
16051606
const stacks = await assembly.selectStacksV2(selectStacks);
16061607

1608+
await this.suggestStacks(ioHelper, assembly, selectStacks, stacks);
1609+
16071610
const ret: DestroyResult = {
16081611
stacks: [],
16091612
};
16101613

1614+
if (stacks.stackCount === 0) {
1615+
await ioHelper.notify(IO.CDK_TOOLKIT_W7011.msg(
1616+
`No stacks match the name(s): ${chalk.red((selectStacks.patterns ?? []).join(', '))}`,
1617+
));
1618+
return ret;
1619+
}
1620+
16111621
const motivation = 'Destroying stacks is an irreversible action';
16121622
const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`;
16131623
const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation }));
@@ -1667,6 +1677,39 @@ export class Toolkit extends CloudAssemblySourceBuilder {
16671677
}
16681678
}
16691679

1680+
/**
1681+
* Warn about destroy patterns that matched no stack, suggesting a close match
1682+
* when one exists (e.g. only the casing differs). Stacks nested inside a stage
1683+
* are considered, so a staged stack can be suggested too.
1684+
*/
1685+
private async suggestStacks(
1686+
ioHelper: IoHelper,
1687+
assembly: StackAssembly,
1688+
selector: StackSelector,
1689+
selected: StackCollection,
1690+
): Promise<void> {
1691+
const patterns = selector.patterns ?? [];
1692+
if (patterns.length === 0) {
1693+
return;
1694+
}
1695+
1696+
const allStacks = await assembly.selectStacksV2(ALL_STACKS);
1697+
1698+
for (const pattern of patterns) {
1699+
const matched = selected.stackArtifacts.some((stack) => picomatch.isMatch(stack.hierarchicalId, pattern));
1700+
if (matched) {
1701+
continue;
1702+
}
1703+
1704+
const closeMatches = allStacks.stackArtifacts
1705+
.filter((stack) => picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase()))
1706+
.map((stack) => stack.hierarchicalId);
1707+
1708+
const suggestion = closeMatches.length > 0 ? ` Do you mean ${chalk.blue(closeMatches.join(', '))}?` : '';
1709+
await ioHelper.notify(IO.CDK_TOOLKIT_W7010.msg(`${chalk.red(pattern)} does not exist.${suggestion}`));
1710+
}
1711+
}
1712+
16701713
/**
16711714
* Create a deployments class
16721715
*/
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as core from 'aws-cdk-lib/core';
2+
3+
/**
4+
* An app with a top-level stack plus a stack nested inside a Stage.
5+
*
6+
* Hierarchical ids: `TopLevelStack` and `Stage/StackInStage`. Used to exercise
7+
* destroy/suggestion behavior across both top-level and nested-stage stacks.
8+
*/
9+
export default async () => {
10+
const app = new core.App({ autoSynth: false });
11+
new core.Stack(app, 'TopLevelStack');
12+
const stage = new core.Stage(app, 'Stage');
13+
new core.Stack(stage, 'StackInStage');
14+
15+
return app.synth();
16+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as core from 'aws-cdk-lib/core';
2+
3+
/**
4+
* An app whose only stack lives inside a Stage (no top-level stacks).
5+
*
6+
* This is the configuration that regressed the original `cdk destroy` warning
7+
* feature: code that only looked at top-level stacks could not see (or suggest)
8+
* stacks nested in a Stage. The stack's hierarchical id is `Stage/StackInStage`.
9+
*/
10+
export default async () => {
11+
const app = new core.App({ autoSynth: false });
12+
const stage = new core.Stage(app, 'Stage');
13+
new core.Stack(stage, 'StackInStage');
14+
15+
return app.synth();
16+
};

packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,203 @@ describe('destroy', () => {
107107
expect(mockDispose).toHaveBeenCalled();
108108
await realDispose();
109109
});
110+
111+
test('warns when no stacks match the given name(s)', async () => {
112+
// WHEN
113+
const cx = await builderFixture(toolkit, 'two-empty-stacks');
114+
await toolkit.destroy(cx, {
115+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist'] },
116+
});
117+
118+
// THEN
119+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
120+
action: 'destroy',
121+
level: 'warn',
122+
code: 'CDK_TOOLKIT_W7011',
123+
message: expect.stringContaining('No stacks match the name(s)'),
124+
}));
125+
expect(mockDestroyStack).not.toHaveBeenCalled();
126+
});
127+
128+
test('suggests a closely matching stack when the name does not exist', async () => {
129+
// WHEN
130+
const cx = await builderFixture(toolkit, 'two-empty-stacks');
131+
await toolkit.destroy(cx, {
132+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stack1'] },
133+
});
134+
135+
// THEN
136+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
137+
action: 'destroy',
138+
level: 'warn',
139+
code: 'CDK_TOOLKIT_W7010',
140+
message: expect.stringContaining('does not exist. Do you mean'),
141+
}));
142+
expect(mockDestroyStack).not.toHaveBeenCalled();
143+
});
144+
145+
test('warns about a missing name but still destroys the matching stacks', async () => {
146+
// WHEN: one name matches (Stack1), the other does not
147+
const cx = await builderFixture(toolkit, 'two-empty-stacks');
148+
await toolkit.destroy(cx, {
149+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stack1', 'DoesNotExist'] },
150+
});
151+
152+
// THEN: warn for the missing name, but no "no stacks match" and the match is destroyed
153+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
154+
level: 'warn',
155+
code: 'CDK_TOOLKIT_W7010',
156+
message: expect.stringContaining(`${chalk.red('DoesNotExist')} does not exist.`),
157+
}));
158+
expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({
159+
code: 'CDK_TOOLKIT_W7011',
160+
}));
161+
expect(mockDestroyStack).toHaveBeenCalledTimes(1);
162+
});
163+
164+
test('destroys all top-level stacks with the MAIN_ASSEMBLY strategy (cdk destroy --all)', async () => {
165+
// WHEN
166+
const cx = await builderFixture(toolkit, 'two-empty-stacks');
167+
await toolkit.destroy(cx, {
168+
stacks: { strategy: StackSelectionStrategy.MAIN_ASSEMBLY },
169+
});
170+
171+
// THEN
172+
expect(mockDestroyStack).toHaveBeenCalledTimes(2);
173+
});
174+
175+
test('destroys the single stack with the ONLY_SINGLE strategy (cdk destroy with no patterns)', async () => {
176+
// WHEN
177+
const cx = await builderFixture(toolkit, 'stack-with-bucket');
178+
await toolkit.destroy(cx, {
179+
stacks: { strategy: StackSelectionStrategy.ONLY_SINGLE },
180+
});
181+
182+
// THEN
183+
expect(mockDestroyStack).toHaveBeenCalledTimes(1);
184+
});
185+
186+
test('warns about every non-existent name when none of them match', async () => {
187+
// WHEN
188+
const cx = await builderFixture(toolkit, 'two-empty-stacks');
189+
await toolkit.destroy(cx, {
190+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['NopeA', 'NopeB'] },
191+
});
192+
193+
// THEN: a per-name warning for each, plus the overall no-match warning
194+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
195+
code: 'CDK_TOOLKIT_W7010',
196+
message: expect.stringContaining(`${chalk.red('NopeA')} does not exist`),
197+
}));
198+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
199+
code: 'CDK_TOOLKIT_W7010',
200+
message: expect.stringContaining(`${chalk.red('NopeB')} does not exist`),
201+
}));
202+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
203+
code: 'CDK_TOOLKIT_W7011',
204+
}));
205+
expect(mockDestroyStack).not.toHaveBeenCalled();
206+
});
207+
208+
describe('stacks nested in a stage', () => {
209+
test('destroys a stack inside a stage by its hierarchical id', async () => {
210+
// WHEN
211+
const cx = await builderFixture(toolkit, 'stack-with-stage');
212+
await toolkit.destroy(cx, {
213+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] },
214+
});
215+
216+
// THEN: only the staged stack is destroyed, not the top-level one
217+
expect(mockDestroyStack).toHaveBeenCalledTimes(1);
218+
expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage');
219+
});
220+
221+
test('destroys a staged stack via a wildcard pattern', async () => {
222+
// WHEN
223+
const cx = await builderFixture(toolkit, 'stack-with-stage');
224+
await toolkit.destroy(cx, {
225+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] },
226+
});
227+
228+
// THEN
229+
expect(mockDestroyStack).toHaveBeenCalledTimes(1);
230+
expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage');
231+
});
232+
233+
test('destroys a stack in a stage-only app (no top-level stacks)', async () => {
234+
// WHEN
235+
const cx = await builderFixture(toolkit, 'stage-only');
236+
await toolkit.destroy(cx, {
237+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] },
238+
});
239+
240+
// THEN
241+
expect(mockDestroyStack).toHaveBeenCalledTimes(1);
242+
});
243+
244+
test('destroys a staged stack via wildcard in a stage-only app', async () => {
245+
// WHEN
246+
const cx = await builderFixture(toolkit, 'stage-only');
247+
await toolkit.destroy(cx, {
248+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] },
249+
});
250+
251+
// THEN
252+
expect(mockDestroyStack).toHaveBeenCalledTimes(1);
253+
});
254+
255+
// Regression for the original revert: a stage-only app must not break the
256+
// warning path (the candidate lookup must see nested-stage stacks).
257+
test('warns without failing for a non-existent name in a stage-only app', async () => {
258+
// WHEN
259+
const cx = await builderFixture(toolkit, 'stage-only');
260+
await toolkit.destroy(cx, {
261+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist/*'] },
262+
});
263+
264+
// THEN
265+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
266+
level: 'warn',
267+
code: 'CDK_TOOLKIT_W7011',
268+
message: expect.stringContaining('No stacks match the name(s)'),
269+
}));
270+
expect(mockDestroyStack).not.toHaveBeenCalled();
271+
});
272+
273+
// Regression core: a close match nested inside a stage must be suggested,
274+
// which only works if the candidate lookup includes nested-stage stacks.
275+
test('suggests a nested-stage stack when only the casing differs', async () => {
276+
// WHEN
277+
const cx = await builderFixture(toolkit, 'stage-only');
278+
await toolkit.destroy(cx, {
279+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/stackinstage'] },
280+
});
281+
282+
// THEN
283+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
284+
level: 'warn',
285+
code: 'CDK_TOOLKIT_W7010',
286+
message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`),
287+
}));
288+
expect(mockDestroyStack).not.toHaveBeenCalled();
289+
});
290+
291+
test('suggests a nested-stage stack for a wildcard pattern that differs only in case', async () => {
292+
// WHEN: a lower-cased wildcard that matches nothing but resembles the staged stack
293+
const cx = await builderFixture(toolkit, 'stage-only');
294+
await toolkit.destroy(cx, {
295+
stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/*'] },
296+
});
297+
298+
// THEN
299+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
300+
level: 'warn',
301+
code: 'CDK_TOOLKIT_W7010',
302+
message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`),
303+
}));
304+
expect(mockDestroyStack).not.toHaveBeenCalled();
305+
});
306+
});
110307
});
111308

112309
function successfulDestroy() {

0 commit comments

Comments
 (0)