Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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:
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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: <DURATION>\n"}
Original file line number Diff line number Diff line change
@@ -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: <DURATION>\n"}
72 changes: 71 additions & 1 deletion packages/aws-cdk/test/commands/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down
48 changes: 48 additions & 0 deletions packages/aws-cdk/test/cxapp/cloud-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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: [],
Expand Down
Loading