Skip to content

Commit

Permalink
feat(cli): deploy/destory require explicit stack selection if app con…
Browse files Browse the repository at this point in the history
…tains more than a single stack (#2772)

To reduce risk to production systems, if an app includes more than a single
stack, "cdk deploy" and "cdk destroy" will fail and require that explicit
stack selector(s) will be specified.

Since wildcards are supported "cdk deploy '*'" will select all stacks.

Added support for stack selectors in "cdk ls"

Closes #2731

BREAKING CHANGE:
* **cli:** if an app includes more than one stack "cdk deploy" and "cdk destroy" now require that an explicit selector will be passed. Use "cdk deploy '*'" if you want to select all stacks.
  • Loading branch information
Elad Ben-Israel authored Jun 17, 2019
1 parent b735d1c commit 118a716
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 36 deletions.
28 changes: 19 additions & 9 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import yargs = require('yargs');
import { bootstrapEnvironment, destroyStack, SDK } from '../lib';
import { environmentsFromDescriptors, globEnvironmentsFromStacks } from '../lib/api/cxapp/environments';
import { execProgram } from '../lib/api/cxapp/exec';
import { AppStacks, ExtendedStackSelection } from '../lib/api/cxapp/stacks';
import { AppStacks, DefaultSelection, ExtendedStackSelection } from '../lib/api/cxapp/stacks';
import { CloudFormationDeploymentTarget, DEFAULT_TOOLKIT_STACK_NAME } from '../lib/api/deployment-target';
import { CdkToolkit } from '../lib/cdk-toolkit';
import { RequireApproval } from '../lib/diff';
Expand Down Expand Up @@ -45,7 +45,7 @@ async function parseCommandLineArguments() {
.option('toolkit-stack-name', { type: 'string', desc: 'The name of the CDK toolkit stack', requiresArg: true })
.option('staging', { type: 'boolean', desc: 'copy assets to the output directory (use --no-staging to disable, needed for local debugging the source files with SAM CLI)', default: true })
.option('output', { type: 'string', alias: 'o', desc: 'emits the synthesized cloud assembly into a directory (default: cdk.out)', requiresArg: true })
.command([ 'list', 'ls' ], 'Lists all stacks in the app', yargs => yargs
.command([ 'list [STACKS..]', 'ls [STACKS..]' ], 'Lists all stacks in the app', yargs => yargs
.option('long', { type: 'boolean', default: false, alias: 'l', desc: 'display environment information for each stack' }))
.command([ 'synthesize [STACKS..]', 'synth [STACKS..]' ], 'Synthesizes and prints the CloudFormation template for this stack', yargs => yargs
.option('exclusively', { type: 'boolean', alias: 'e', desc: 'only deploy requested stacks, don\'t include dependencies' }))
Expand Down Expand Up @@ -173,7 +173,7 @@ async function initCommandLine() {
switch (command) {
case 'ls':
case 'list':
return await cliList({ long: args.long });
return await cliList(args.STACKS, { long: args.long });

case 'diff':
return await cli.diff({
Expand Down Expand Up @@ -276,7 +276,10 @@ async function initCommandLine() {
// Only autoselect dependencies if it doesn't interfere with user request or output options
const autoSelectDependencies = !exclusively;

const stacks = await appStacks.selectStacks(stackNames, autoSelectDependencies ? ExtendedStackSelection.Upstream : ExtendedStackSelection.None);
const stacks = await appStacks.selectStacks(stackNames, {
extend: autoSelectDependencies ? ExtendedStackSelection.Upstream : ExtendedStackSelection.None,
defaultBehavior: DefaultSelection.AllStacks
});

// if we have a single stack, print it to STDOUT
if (stacks.length === 1) {
Expand All @@ -295,11 +298,12 @@ async function initCommandLine() {
return stacks.map(s => s.template);
}

return appStacks.assembly!.directory;
// no output to stdout
return undefined;
}

async function cliList(options: { long?: boolean } = { }) {
const stacks = await appStacks.listStacks();
async function cliList(selectors: string[], options: { long?: boolean } = { }) {
const stacks = await appStacks.selectStacks(selectors, { defaultBehavior: DefaultSelection.AllStacks });

// if we are in "long" mode, emit the array as-is (JSON/YAML)
if (options.long) {
Expand All @@ -322,7 +326,10 @@ async function initCommandLine() {
}

async function cliDestroy(stackNames: string[], exclusively: boolean, force: boolean, roleArn: string | undefined) {
const stacks = await appStacks.selectStacks(stackNames, exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Downstream);
const stacks = await appStacks.selectStacks(stackNames, {
extend: exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Downstream,
defaultBehavior: DefaultSelection.OnlySingle
});

// The stacks will have been ordered for deployment, so reverse them for deletion.
stacks.reverse();
Expand Down Expand Up @@ -351,7 +358,10 @@ async function initCommandLine() {
* Match a single stack from the list of available stacks
*/
async function findStack(name: string): Promise<string> {
const stacks = await appStacks.selectStacks([name], ExtendedStackSelection.None);
const stacks = await appStacks.selectStacks([name], {
extend: ExtendedStackSelection.None,
defaultBehavior: DefaultSelection.None
});

// Could have been a glob so check that we evaluated to exactly one
if (stacks.length > 1) {
Expand Down
5 changes: 3 additions & 2 deletions packages/aws-cdk/lib/api/cxapp/environments.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import cxapi = require('@aws-cdk/cx-api');
import minimatch = require('minimatch');
import { AppStacks, ExtendedStackSelection } from './stacks';
import { AppStacks } from './stacks';

export async function globEnvironmentsFromStacks(appStacks: AppStacks, environmentGlobs: string[]): Promise<cxapi.Environment[]> {
if (environmentGlobs.length === 0) {
environmentGlobs = [ '**' ]; // default to ALL
}
const stacks = await appStacks.selectStacks([], ExtendedStackSelection.None);

const stacks = await appStacks.listStacks();

const availableEnvironments = distinct(stacks.map(stack => stack.environment)
.filter(env => env !== undefined) as cxapi.Environment[]);
Expand Down
54 changes: 49 additions & 5 deletions packages/aws-cdk/lib/api/cxapp/stacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,37 @@ export interface AppStacksProps {
synthesizer: Synthesizer;
}

export interface SelectStacksOptions {
/**
* Extend the selection to upstread/downstream stacks
* @default ExtendedStackSelection.None only select the specified stacks.
*/
extend?: ExtendedStackSelection;

/**
* The behavior if if no selectors are privided.
*/
defaultBehavior: DefaultSelection;
}

export enum DefaultSelection {
/**
* Returns an empty selection in case there are no selectors.
*/
None = 'none',

/**
* If the app includes a single stack, returns it. Otherwise throws an exception.
* This behavior is used by "deploy".
*/
OnlySingle = 'single',

/**
* If no selectors are provided, returns all stacks in the app.
*/
AllStacks = 'all',
}

/**
* Routines to get stacks from an app
*
Expand All @@ -72,7 +103,7 @@ export class AppStacks {
* It's an error if there are no stacks to select, or if one of the requested parameters
* refers to a nonexistant stack.
*/
public async selectStacks(selectors: string[], extendedSelection: ExtendedStackSelection): Promise<cxapi.CloudFormationStackArtifact[]> {
public async selectStacks(selectors: string[], options: SelectStacksOptions): Promise<cxapi.CloudFormationStackArtifact[]> {
selectors = selectors.filter(s => s != null); // filter null/undefined

const stacks = await this.listStacks();
Expand All @@ -81,9 +112,21 @@ export class AppStacks {
}

if (selectors.length === 0) {
// remove non-auto deployed Stacks
debug('Stack name not specified, so defaulting to all available stacks: ' + listStackNames(stacks));
return stacks;
switch (options.defaultBehavior) {
case DefaultSelection.AllStacks:
return stacks;
case DefaultSelection.None:
return [];
case DefaultSelection.OnlySingle:
if (stacks.length === 1) {
return stacks;
} else {
throw new Error(`Since this app includes more than a single stack, specify which stacks to use (wildcards are supported)\n` +
`Stacks: ${stacks.map(x => x.name).join(' ')}`);
}
default:
throw new Error(`invalid default behavior: ${options.defaultBehavior}`);
}
}

const allStacks = new Map<string, cxapi.CloudFormationStackArtifact>();
Expand All @@ -108,7 +151,8 @@ export class AppStacks {
}
}

switch (extendedSelection) {
const extend = options.extend || ExtendedStackSelection.None;
switch (extend) {
case ExtendedStackSelection.Downstream:
includeDownstreamStacks(selectedStacks, allStacks);
break;
Expand Down
16 changes: 9 additions & 7 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import colors = require('colors/safe');
import fs = require('fs-extra');
import { format } from 'util';
import { AppStacks, ExtendedStackSelection, Tag } from "./api/cxapp/stacks";
import { AppStacks, DefaultSelection, ExtendedStackSelection, Tag } from "./api/cxapp/stacks";
import { IDeploymentTarget } from './api/deployment-target';
import { printSecurityDiff, printStackDiff, RequireApproval } from './diff';
import { data, error, highlight, print, success } from './logging';
Expand Down Expand Up @@ -38,9 +38,10 @@ export class CdkToolkit {
}

public async diff(options: DiffOptions): Promise<number> {
const stacks = await this.appStacks.selectStacks(
options.stackNames,
options.exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream);
const stacks = await this.appStacks.selectStacks(options.stackNames, {
extend: options.exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream,
defaultBehavior: DefaultSelection.AllStacks
});

const strict = !!options.strict;
const contextLines = options.contextLines || 3;
Expand Down Expand Up @@ -75,9 +76,10 @@ export class CdkToolkit {
public async deploy(options: DeployOptions) {
const requireApproval = options.requireApproval !== undefined ? options.requireApproval : RequireApproval.Broadening;

const stacks = await this.appStacks.selectStacks(
options.stackNames,
options.exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream);
const stacks = await this.appStacks.selectStacks(options.stackNames, {
extend: options.exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream,
defaultBehavior: DefaultSelection.OnlySingle
});

for (const stack of stacks) {
if (stacks.length !== 1) { highlight(stack.name); }
Expand Down
73 changes: 60 additions & 13 deletions packages/aws-cdk/test/api/test.stacks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import cxapi = require('@aws-cdk/cx-api');
import { Test } from 'nodeunit';
import { SDK } from '../../lib';
import { AppStacks, ExtendedStackSelection } from '../../lib/api/cxapp/stacks';
import { AppStacks, DefaultSelection } from '../../lib/api/cxapp/stacks';
import { Configuration } from '../../lib/settings';
import { testAssembly } from '../util';

Expand All @@ -25,14 +25,12 @@ const FIXED_RESULT = testAssembly({
export = {
async 'do not throw when selecting stack without errors'(test: Test) {
// GIVEN
const stacks = new AppStacks({
configuration: new Configuration(),
aws: new SDK(),
synthesizer: async () => FIXED_RESULT,
});
const stacks = testStacks();

// WHEN
const selected = await stacks.selectStacks(['withouterrors'], ExtendedStackSelection.None);
const selected = await stacks.selectStacks(['withouterrors'], {
defaultBehavior: DefaultSelection.AllStacks
});

// THEN
test.equal(selected[0].template.resource, 'noerrorresource');
Expand All @@ -42,20 +40,69 @@ export = {

async 'do throw when selecting stack with errors'(test: Test) {
// GIVEN
const stacks = new AppStacks({
configuration: new Configuration(),
aws: new SDK(),
synthesizer: async () => FIXED_RESULT,
});
const stacks = testStacks();

// WHEN
try {
await stacks.selectStacks(['witherrors'], ExtendedStackSelection.None);
await stacks.selectStacks(['witherrors'], {
defaultBehavior: DefaultSelection.AllStacks
});

test.ok(false, 'Did not get exception');
} catch (e) {
test.ok(/Found errors/.test(e.toString()), 'Wrong error');
}

test.done();
},

async 'select behavior: all'(test: Test) {
// GIVEN
const stacks = testStacks();

// WHEN
const x = await stacks.selectStacks([], { defaultBehavior: DefaultSelection.AllStacks });

// THEN
test.deepEqual(x.length, 2);
test.done();
},

async 'select behavior: none'(test: Test) {
// GIVEN
const stacks = testStacks();

// WHEN
const x = await stacks.selectStacks([], { defaultBehavior: DefaultSelection.None });

// THEN
test.deepEqual(x.length, 0);
test.done();
},

async 'select behavior: single'(test: Test) {
// GIVEN
const stacks = testStacks();

// WHEN
let thrown: string | undefined;
try {
await stacks.selectStacks([], { defaultBehavior: DefaultSelection.OnlySingle });
} catch (e) {
thrown = e.message;
}

// THEN
test.ok(thrown && thrown.includes('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported)'));
test.done();
}

};

function testStacks() {
return new AppStacks({
configuration: new Configuration(),
aws: new SDK(),
synthesizer: async () => FIXED_RESULT,
});
}

0 comments on commit 118a716

Please sign in to comment.