Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): deploy/destory require explicit stack selection if app contains more than a single stack #2772

Merged
merged 3 commits into from
Jun 17, 2019
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
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,
});
}