Skip to content

Commit

Permalink
fix(toolkit): do not deploy empty stacks (#3144)
Browse files Browse the repository at this point in the history
* fix(toolkit): do not deploy empty stacks

* use warning

* destroy empty stack if it exists

* change mode to ForReading

* force destroy

* optional roleArn

* remove 'skipping deployment' when destroying

* set exclusively to true

* make deployesque

* add integ test
  • Loading branch information
jogold authored and mergify[bot] committed Aug 28, 2019
1 parent e9eb183 commit 64ace90
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 37 deletions.
45 changes: 10 additions & 35 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import colors = require('colors/safe');
import path = require('path');
import yargs = require('yargs');

import { bootstrapEnvironment, BootstrapEnvironmentProps, destroyStack, SDK } from '../lib';
import { bootstrapEnvironment, BootstrapEnvironmentProps, SDK } from '../lib';
import { environmentsFromDescriptors, globEnvironmentsFromStacks } from '../lib/api/cxapp/environments';
import { execProgram } from '../lib/api/cxapp/exec';
import { AppStacks, DefaultSelection, ExtendedStackSelection } from '../lib/api/cxapp/stacks';
Expand All @@ -19,9 +19,6 @@ import { serializeStructure } from '../lib/serialize';
import { Configuration, Settings } from '../lib/settings';
import version = require('../lib/version');

// tslint:disable-next-line:no-var-requires
const promptly = require('promptly');

// tslint:disable:no-shadowed-variable max-line-length
async function parseCommandLineArguments() {
const initTemplateLanuages = await availableInitLanguages;
Expand Down Expand Up @@ -201,11 +198,18 @@ async function initCommandLine() {
requireApproval: configuration.settings.get(['requireApproval']),
ci: args.ci,
reuseAssets: args['build-exclude'],
tags: configuration.settings.get(['tags'])
tags: configuration.settings.get(['tags']),
sdk: aws,
});

case 'destroy':
return await cliDestroy(args.STACKS, args.exclusively, args.force, args.roleArn);
return await cli.destroy({
stackNames: args.STACKS,
exclusively: args.exclusively,
force: args.force,
roleArn: args.roleArn,
sdk: aws,
});

case 'synthesize':
case 'synth':
Expand Down Expand Up @@ -332,35 +336,6 @@ async function initCommandLine() {
return 0; // exit-code
}
async function cliDestroy(stackNames: string[], exclusively: boolean, force: boolean, roleArn: string | undefined) {
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();
if (!force) {
// tslint:disable-next-line:max-line-length
const confirmed = await promptly.confirm(`Are you sure you want to delete: ${colors.blue(stacks.map(s => s.name).join(', '))} (y/n)?`);
if (!confirmed) {
return;
}
}
for (const stack of stacks) {
success('%s: destroying...', colors.blue(stack.name));
try {
await destroyStack({ stack, sdk: aws, deployName: stack.name, roleArn });
success('\n ✅ %s: destroyed', colors.blue(stack.name));
} catch (e) {
error('\n ❌ %s: destroy failed', colors.blue(stack.name), e);
throw e;
}
}
}
/**
* Match a single stack from the list of available stacks
*/
Expand Down
91 changes: 90 additions & 1 deletion packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import colors = require('colors/safe');
import fs = require('fs-extra');
import { format } from 'util';
import { Mode } from './api/aws-auth/credentials';
import { AppStacks, DefaultSelection, ExtendedStackSelection, Tag } from "./api/cxapp/stacks";
import { destroyStack } from './api/deploy-stack';
import { IDeploymentTarget } from './api/deployment-target';
import { stackExists } from './api/util/cloudformation';
import { ISDK } from './api/util/sdk';
import { printSecurityDiff, printStackDiff, RequireApproval } from './diff';
import { data, error, highlight, print, success } from './logging';
import { data, error, highlight, print, success, warning } from './logging';
import { deserializeStructure } from './serialize';

// tslint:disable-next-line:no-var-requires
Expand Down Expand Up @@ -90,6 +94,24 @@ export class CdkToolkit {
throw new Error(`Stack ${stack.name} does not define an environment, and AWS credentials could not be obtained from standard locations or no region was configured.`);
}

if (Object.keys(stack.template.Resources || {}).length === 0) { // The generated stack has no resources
const cfn = await options.sdk.cloudFormation(stack.environment.account, stack.environment.region, Mode.ForReading);
if (!await stackExists(cfn, stack.name)) {
warning('%s: stack has no resources, skipping deployment.', colors.bold(stack.name));
} else {
warning('%s: stack has no resources, deleting existing stack.', colors.bold(stack.name));
await this.destroy({
stackNames: [stack.name],
exclusively: true,
force: true,
roleArn: options.roleArn,
sdk: options.sdk,
fromDeploy: true,
});
}
continue;
}

if (requireApproval !== RequireApproval.Never) {
const currentTemplate = await this.provisioner.readCurrentTemplate(stack);
if (printSecurityDiff(currentTemplate, stack, requireApproval)) {
Expand Down Expand Up @@ -152,6 +174,36 @@ export class CdkToolkit {
}
}
}

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

// The stacks will have been ordered for deployment, so reverse them for deletion.
stacks.reverse();

if (!options.force) {
// tslint:disable-next-line:max-line-length
const confirmed = await promptly.confirm(`Are you sure you want to delete: ${colors.blue(stacks.map(s => s.name).join(', '))} (y/n)?`);
if (!confirmed) {
return;
}
}

const action = options.fromDeploy ? 'deploy' : 'destroy';
for (const stack of stacks) {
success('%s: destroying...', colors.blue(stack.name));
try {
await destroyStack({ stack, sdk: options.sdk, deployName: stack.name, roleArn: options.roleArn });
success(`\n ✅ %s: ${action}ed`, colors.blue(stack.name));
} catch (e) {
error(`\n ❌ %s: ${action} failed`, colors.blue(stack.name), e);
throw e;
}
}
}
}

export interface DiffOptions {
Expand Down Expand Up @@ -244,4 +296,41 @@ export interface DeployOptions {
* Tags to pass to CloudFormation for deployment
*/
tags?: Tag[];

/**
* AWS SDK
*/
sdk: ISDK;
}

export interface DestroyOptions {
/**
* The names of the stacks to delete
*/
stackNames: string[];

/**
* Whether to exclude stacks that depend on the stacks to be deleted
*/
exclusively: boolean;

/**
* Whether to skip prompting for confirmation
*/
force: boolean;

/**
* The arn of the IAM role to use
*/
roleArn?: string;

/**
* AWS SDK
*/
sdk: ISDK;

/**
* Whether the destroy request came from a deploy.
*/
fromDeploy?: boolean
}
12 changes: 12 additions & 0 deletions packages/aws-cdk/test/integ/cli/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ class ImportVpcStack extends cdk.Stack {
}
}

class ConditionalResourceStack extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);

if (!process.env.NO_RESOURCE) {
new iam.User(this, 'User');
}
}
}

const stackPrefix = process.env.STACK_NAME_PREFIX || 'cdk-toolkit-integration';

const app = new cdk.App();
Expand Down Expand Up @@ -154,4 +164,6 @@ if (process.env.ENABLE_VPC_TESTING) { // Gating so we don't do context fetching
new ImportVpcStack(app, `${stackPrefix}-import-vpc`, { env });
}

new ConditionalResourceStack(app, `${stackPrefix}-conditional-resource`)

app.synth();
33 changes: 33 additions & 0 deletions packages/aws-cdk/test/integ/cli/test-cdk-no-resource.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash
set -euo pipefail
scriptdir=$(cd $(dirname $0) && pwd)
source ${scriptdir}/common.bash
# ----------------------------------------------------------

setup

# Deploy without resource
NO_RESOURCE="TRUE" cdk deploy ${STACK_NAME_PREFIX}-conditional-resource

# Verify that deploy has been skipped
deployed=1
aws cloudformation describe-stacks --stack-name ${STACK_NAME_PREFIX}-conditional-resource > /dev/null 2>&1 || deployed=0

if [ $deployed -ne 0 ]; then
fail 'Stack has been deployed'
fi

# Deploy the stack with resources
cdk deploy ${STACK_NAME_PREFIX}-conditional-resource

# Now, deploy the stack without resources
NO_RESOURCE="TRUE" cdk deploy ${STACK_NAME_PREFIX}-conditional-resource

# Verify that the stack has been destroyed
destroyed=0
aws cloudformation describe-stacks --stack-name ${STACK_NAME_PREFIX}-conditional-resource > /dev/null 2>&1 || destroyed=1

if [ $destroyed -ne 1 ]; then
fail 'Stack has not been destroyed'
fi

3 changes: 2 additions & 1 deletion packages/aws-cdk/test/test.cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import nodeunit = require('nodeunit');
import { AppStacks, Tag } from '../lib/api/cxapp/stacks';
import { DeployStackResult } from '../lib/api/deploy-stack';
import { DeployStackOptions, IDeploymentTarget, Template } from '../lib/api/deployment-target';
import { SDK } from '../lib/api/util/sdk';
import { CdkToolkit } from '../lib/cdk-toolkit';

export = nodeunit.testCase({
Expand All @@ -19,7 +20,7 @@ export = nodeunit.testCase({
});

// WHEN
toolkit.deploy({ stackNames: ['Test-Stack-A', 'Test-Stack-B'] });
toolkit.deploy({ stackNames: ['Test-Stack-A', 'Test-Stack-B'], sdk: new SDK() });

// THEN
test.done();
Expand Down

0 comments on commit 64ace90

Please sign in to comment.