diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index dfa221d144bcc..1725029fa0fba 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -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'; @@ -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; @@ -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': @@ -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 */ diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 0178bcc3a38a9..97bcd998316bc 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -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 @@ -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)) { @@ -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 { @@ -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 } diff --git a/packages/aws-cdk/test/integ/cli/app/app.js b/packages/aws-cdk/test/integ/cli/app/app.js index 7a53fd51d916c..62b18880c5fb3 100644 --- a/packages/aws-cdk/test/integ/cli/app/app.js +++ b/packages/aws-cdk/test/integ/cli/app/app.js @@ -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(); @@ -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(); diff --git a/packages/aws-cdk/test/integ/cli/test-cdk-no-resource.sh b/packages/aws-cdk/test/integ/cli/test-cdk-no-resource.sh new file mode 100644 index 0000000000000..1f589dc293758 --- /dev/null +++ b/packages/aws-cdk/test/integ/cli/test-cdk-no-resource.sh @@ -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 + diff --git a/packages/aws-cdk/test/test.cdk-toolkit.ts b/packages/aws-cdk/test/test.cdk-toolkit.ts index 6b93b57dd75cf..db14f44c3a1f9 100644 --- a/packages/aws-cdk/test/test.cdk-toolkit.ts +++ b/packages/aws-cdk/test/test.cdk-toolkit.ts @@ -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({ @@ -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();