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

fix(toolkit): do not deploy empty stacks #3144

Merged
merged 16 commits into from
Aug 28, 2019
Merged
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