diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index c08bd4e53..bf3ce7b63 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -163,8 +163,7 @@ export class StackAssembly implements IReadableCloudAssembly { 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(' · ')}`); + throw new ToolkitError('MultipleStacksWithoutSelector', multipleStacksWithoutSelectorMessage(topLevelStacks, allStacks)); } return { stacks: new StackCollection(this, topLevelStacks) }; default: @@ -337,3 +336,33 @@ async function includeUpstreamStacks( await ioHelper.notify(IO.CDK_TOOLKIT_I1002.msg(`Including dependency stacks: ${chalk.bold(added.join(', '))}`)); } } + +/** + * Build the error message shown when the app has more than one stack but a + * single-stack selection was requested without a selector. + * + * When some of the stacks are nested inside a Stage (i.e. they are not + * top-level stacks, their hierarchical id is namespaced like `StageName/StackName`), + * we additionally point the user at a wildcard pattern that selects them, e.g. + * `'StageName/*'`. Otherwise users are left guessing, since a bare stack name or + * `--all` is not the most obvious way to target stacks inside a Stage. + */ +export function multipleStacksWithoutSelectorMessage( + topLevelStacks: cxapi.CloudFormationStackArtifact[], + allStacks: cxapi.CloudFormationStackArtifact[], +): string { + const topLevelSet = new Set(topLevelStacks); + const stagedStacks = allStacks.filter(stack => !topLevelSet.has(stack)); + + let message = '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(' · ')}`; + + if (stagedStacks.length > 0) { + const stagePatterns = Array.from(new Set(stagedStacks.map(stack => `${stack.hierarchicalId.split('/')[0]}/*`))); + message += '\n' + + 'Some of these stacks are nested inside a Stage. To select the stacks in a Stage, ' + + `use a pattern that matches their full path, e.g. ${stagePatterns.map(p => `'${p}'`).join(', ')}`; + } + + return message; +} diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-message.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-message.test.ts new file mode 100644 index 000000000..993c23ec7 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-message.test.ts @@ -0,0 +1,51 @@ +import type * as cxapi from '@aws-cdk/cloud-assembly-api'; +import { multipleStacksWithoutSelectorMessage } from '../../../lib/api/cloud-assembly/private/stack-assembly'; + +function fakeStack(hierarchicalId: string): cxapi.CloudFormationStackArtifact { + return { hierarchicalId } as cxapi.CloudFormationStackArtifact; +} + +describe('multipleStacksWithoutSelectorMessage', () => { + test('guides Stage users towards the wildcard pattern when stacks are nested in a Stage', () => { + // GIVEN - all stacks live inside a Stage (no top-level stacks) + const staged = [fakeStack('MyStage/StackA'), fakeStack('MyStage/StackB')]; + + // WHEN + const message = multipleStacksWithoutSelectorMessage([], staged); + + // THEN + expect(message).toContain('Since this app includes more than a single stack'); + expect(message).toContain('Some of these stacks are nested inside a Stage'); + expect(message).toContain("'MyStage/*'"); + }); + + test('deduplicates stage patterns across multiple stages', () => { + // GIVEN + const staged = [ + fakeStack('StageOne/StackA'), + fakeStack('StageOne/StackB'), + fakeStack('StageTwo/StackC'), + ]; + + // WHEN + const message = multipleStacksWithoutSelectorMessage([], staged); + + // THEN + expect(message).toContain("'StageOne/*'"); + expect(message).toContain("'StageTwo/*'"); + // `StageOne/*` should only appear once even though two stacks belong to it + expect(message.match(/'StageOne\/\*'/g)).toHaveLength(1); + }); + + test('does not mention Stages for a flat app with only top-level stacks', () => { + // GIVEN + const topLevel = [fakeStack('StackA'), fakeStack('StackB')]; + + // WHEN - top-level stacks are the same references as the full list + const message = multipleStacksWithoutSelectorMessage(topLevel, topLevel); + + // THEN + expect(message).toContain('Since this app includes more than a single stack'); + expect(message).not.toContain('Some of these stacks are nested inside a Stage'); + }); +}); diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/deploy/multi-stack_selection_no_selector_on_a_Stage-nested_app_fails_with_guidance_towards_the_Stage_wildcard_pattern.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/multi-stack_selection_no_selector_on_a_Stage-nested_app_fails_with_guidance_towards_the_Stage_wildcard_pattern.ndjson new file mode 100644 index 000000000..2a0c4c630 --- /dev/null +++ b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/multi-stack_selection_no_selector_on_a_Stage-nested_app_fails_with_guidance_towards_the_Stage_wildcard_pattern.ndjson @@ -0,0 +1,2 @@ +{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/deploy/multi-stack_selection_no_selector_on_a_flat_multi-stack_app_fails_without_mentioning_Stages.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/multi-stack_selection_no_selector_on_a_flat_multi-stack_app_fails_without_mentioning_Stages.ndjson new file mode 100644 index 000000000..2a0c4c630 --- /dev/null +++ b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/multi-stack_selection_no_selector_on_a_flat_multi-stack_app_fails_without_mentioning_Stages.ndjson @@ -0,0 +1,2 @@ +{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} diff --git a/packages/aws-cdk/test/commands/deploy.test.ts b/packages/aws-cdk/test/commands/deploy.test.ts index b8d5f2387..27e67c8fe 100644 --- a/packages/aws-cdk/test/commands/deploy.test.ts +++ b/packages/aws-cdk/test/commands/deploy.test.ts @@ -1,6 +1,6 @@ import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; import { Toolkit } from '@aws-cdk/toolkit-lib'; -import { Deployments, selectAllTopLevel, selectExact, selectWithUpstream } from '../../lib/api'; +import { Deployments, selectAllTopLevel, selectExact, selectOnlySingle, selectWithUpstream } from '../../lib/api'; import { IO } from '../../lib/api-private'; import { CdkToolkit } from '../../lib/cli/cdk-toolkit'; import { CliIoHost } from '../../lib/cli/io-host'; @@ -57,6 +57,39 @@ async function makeToolkit(stacks: TestStackArtifact[] = [STACK_A, STACK_B]) { }); } +// A Stage-nested app: there are no top-level stacks, both stacks live inside a +// Stage, so their hierarchical ids are namespaced like `MyStage/StackName`. +// A bare `cdk deploy` (no selector, no `--all`) therefore cannot pick a single +// stack and must guide the user towards the Stage wildcard pattern. +async function makeStagedToolkit() { + cloudExecutable = await MockCloudExecutable.create({ + stacks: [], + nestedAssemblies: [{ + stacks: [ + { + stackName: 'StackA', + template: { Resources: { TemplateName: { Type: 'AWS::CDK::Test' } } }, + env: 'aws://123456789012/bermuda-triangle-1', + displayName: 'MyStage/StackA', + }, + { + stackName: 'StackB', + template: { Resources: { TemplateName: { Type: 'AWS::CDK::Test' } } }, + env: 'aws://123456789012/bermuda-triangle-1', + displayName: 'MyStage/StackB', + }, + ], + }], + }, undefined, ioHost, 'deploy'); + return new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); +} + beforeEach(async () => { // Mirror the destroy test setup: a fresh singleton state and fresh mocks per test. jest.resetAllMocks(); @@ -372,6 +405,43 @@ describe('multi-stack selection', () => { expect(cloudFormation.deployStack).toHaveBeenCalledTimes(2); expect(ioHost.stackProgress).toBe(StackActivityProgress.ERRORS_ONLY); }); + + test('no selector on a Stage-nested app fails with guidance towards the Stage wildcard pattern', async () => { + // GIVEN an app whose stacks all live inside a Stage. + toolkit = await makeStagedToolkit(); + + // WHEN a bare `cdk deploy` runs (no selector, no `--all`), THEN the command + // synthesizes and then aborts at stack selection: the snapshot captures the + // IO stream up to that point. The thrown error additionally points the user + // at the pattern that selects the Stage's stacks (e.g. `'MyStage/*'`), + // instead of only suggesting `--all`. + const error = await toolkit.deploy({ + selector: selectOnlySingle(), + deploymentMethod: { method: 'change-set' }, + requireApproval: RequireApproval.NEVER, + }).catch((e) => e); + + expect(stripAnsi(error.message)).toContain('Since this app includes more than a single stack'); + expect(stripAnsi(error.message)).toContain('Some of these stacks are nested inside a Stage'); + expect(stripAnsi(error.message)).toContain("'MyStage/*'"); + // Selection failed before anything was deployed. + expect(cloudFormation.deployStack).not.toHaveBeenCalled(); + }); + + test('no selector on a flat multi-stack app fails without mentioning Stages', async () => { + // GIVEN a flat app with only top-level stacks (the default STACK_A/STACK_B). + // WHEN a bare `cdk deploy` runs, THEN it still fails for lack of a selector, + // but the Stage-specific guidance must not appear. + const error = await toolkit.deploy({ + selector: selectOnlySingle(), + deploymentMethod: { method: 'change-set' }, + requireApproval: RequireApproval.NEVER, + }).catch((e) => e); + + expect(stripAnsi(error.message)).toContain('Since this app includes more than a single stack'); + expect(stripAnsi(error.message)).not.toContain('Some of these stacks are nested inside a Stage'); + expect(cloudFormation.deployStack).not.toHaveBeenCalled(); + }); }); describe('deploy parameters forwarded to CloudFormation', () => { diff --git a/packages/aws-cdk/test/cxapp/cloud-assembly.test.ts b/packages/aws-cdk/test/cxapp/cloud-assembly.test.ts index bc7b9ecd5..7c80eaf21 100644 --- a/packages/aws-cdk/test/cxapp/cloud-assembly.test.ts +++ b/packages/aws-cdk/test/cxapp/cloud-assembly.test.ts @@ -115,6 +115,30 @@ test('select behavior with nested assemblies: single', async () => { .rejects.toThrow('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`'); }); +test('single-stack selection error guides Stage users towards the wildcard pattern', async () => { + // GIVEN - an app whose stacks all live inside a Stage (no top-level stacks), + // so their hierarchical ids are namespaced like `MyStage/StackName`. + const cxasm = await testStagedCloudAssembly(); + + // WHEN / THEN - the error should point the user at the pattern that selects + // the stacks in that Stage, e.g. `'MyStage/*'`, instead of only suggesting `--all`. + await expect(cxasm.selectStacksV2({ strategy: StackSelectionStrategy.ONLY_SINGLE })) + .rejects.toThrow('Some of these stacks are nested inside a Stage'); + await expect(cxasm.selectStacksV2({ strategy: StackSelectionStrategy.ONLY_SINGLE })) + .rejects.toThrow(/'MyStage\/\*'/); +}); + +test('single-stack selection error without Stages does not mention Stages', async () => { + // GIVEN - a flat app with only top-level stacks + const cxasm = await testCloudAssembly(); + + // WHEN / THEN + await expect(cxasm.selectStacksV2({ strategy: StackSelectionStrategy.ONLY_SINGLE })) + .rejects.toThrow('Since this app includes more than a single stack'); + await expect(cxasm.selectStacksV2({ strategy: StackSelectionStrategy.ONLY_SINGLE })) + .rejects.not.toThrow('Some of these stacks are nested inside a Stage'); +}); + test('select behavior with nested assemblies: repeat', async() => { // GIVEN const cxasm = await testNestedCloudAssembly(); @@ -291,6 +315,30 @@ async function testCloudAssembly({ env }: { env?: string; versionReporting?: boo return cloudExec.synthesize(); } +async function testStagedCloudAssembly() { + const cloudExec = await MockCloudExecutable.create({ + // No top-level stacks: everything lives inside a Stage. + stacks: [], + nestedAssemblies: [{ + stacks: [ + { + stackName: 'StackA', + template: { resource: 'resourceA' }, + displayName: 'MyStage/StackA', + }, + { + stackName: 'StackB', + template: { resource: 'resourceB' }, + displayName: 'MyStage/StackB', + }, + ], + }], + }); + + const asm = await cloudExec.synthesize(); + return cliAssemblyWithForcedVersion(asm, '30.0.0'); +} + async function testCloudAssemblyNoStacks() { const cloudExec = await MockCloudExecutable.create({ stacks: [],