From d4a132bff91ab8e78ed38dc5ee41842554347ecf Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Wed, 19 Feb 2020 13:38:55 +0200 Subject: [PATCH] feat(cli): faster "no-op" deployments (#6346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): skip deployment if template did not change Bail-out before executing and creating change sets when the currently deployed template is identical to the one we are about to deploy. This resolves #6046, where a stack that contains nested stack(s) will always try to update since CloudFormation assumes the nested template might have changed. In the CDK, since nested template URLs are immutable, we can trust that the URL will be changed, and therefore invalidate the *parent* template. This also fixes the bug described in #2553, where a stack that includes a `Transform` always fail to deploy with `No updates are to be performed` when there are no changes to it. The switch `--force` can be used to override this behavior. Added unit test and performed a few manual tests to verify both bugs are resolved. Resolves #6216 * Add alias -f for —force * Add integration test Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- packages/aws-cdk/README.md | 5 + packages/aws-cdk/bin/cdk.ts | 6 +- packages/aws-cdk/lib/api/deploy-stack.ts | 76 +++++++- packages/aws-cdk/lib/api/deployment-target.ts | 24 ++- packages/aws-cdk/lib/cdk-toolkit.ts | 9 +- .../aws-cdk/test/api/deploy-stack.test.ts | 168 +++++++++++++++++- packages/aws-cdk/test/integ/cli/app/app.js | 3 + .../test/integ/cli/app/nested-stack.js | 20 +++ packages/aws-cdk/test/integ/cli/common.bash | 2 + .../test/integ/cli/test-cdk-fast-deploy.sh | 51 ++++++ 10 files changed, 342 insertions(+), 22 deletions(-) create mode 100644 packages/aws-cdk/test/integ/cli/app/nested-stack.js create mode 100755 packages/aws-cdk/test/integ/cli/test-cdk-fast-deploy.sh diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 53f4745256fa5..9145aa5639e42 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -128,6 +128,11 @@ bootstrapped (using `cdk bootstrap`), only stacks that are not using assets and $ cdk deploy --app='node bin/main.js' MyStackName ``` +Before creating a change set, `cdk deploy` will compare the template of the +currently deployed stack to the template that is about to be deployed and will +skip deployment if they are identical. Use `--force` to override this behavior +and always deploy the stack. + #### `cdk destroy` Deletes a stack from it's environment. This will cause the resources in the stack to be destroyed (unless they were configured with a `DeletionPolicy` of `Retain`). During the stack destruction, the command will output progress diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 9739c234e98f7..b16b4179fed20 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -67,7 +67,8 @@ async function parseCommandLineArguments() { .option('ci', { type: 'boolean', desc: 'Force CI detection (deprecated)', default: process.env.CI !== undefined }) .option('notification-arns', {type: 'array', desc: 'ARNs of SNS topics that CloudFormation will notify with stack related events', nargs: 1, requiresArg: true}) .option('tags', { type: 'array', alias: 't', desc: 'Tags to add to the stack (KEY=VALUE)', nargs: 1, requiresArg: true }) - .option('execute', {type: 'boolean', desc: 'Whether to execute ChangeSet (--no-execute will NOT execute the ChangeSet)', default: true}) + .option('execute', { type: 'boolean', desc: 'Whether to execute ChangeSet (--no-execute will NOT execute the ChangeSet)', default: true }) + .option('force', { alias: 'f', type: 'boolean', desc: 'Always deploy stack even if templates are identical', default: false }) ) .command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs .option('exclusively', { type: 'boolean', alias: 'e', desc: 'Only destroy requested stacks, don\'t include dependees' }) @@ -224,7 +225,8 @@ async function initCommandLine() { reuseAssets: args['build-exclude'], tags: configuration.settings.get(['tags']), sdk: aws, - execute: args.execute + execute: args.execute, + force: args.force, }); case 'destroy': diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index d730ea84eb725..0a2e817d734c3 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -5,7 +5,7 @@ import * as uuid from 'uuid'; import { Tag } from "../api/cxapp/stacks"; import { prepareAssets } from '../assets'; import { debug, error, print } from '../logging'; -import { toYAML } from '../serialize'; +import { deserializeStructure, toYAML } from '../serialize'; import { Mode } from './aws-auth/credentials'; import { ToolkitInfo } from './toolkit-info'; import { changeSetHasNoChanges, describeStack, stackExists, stackFailedCreating, waitForChangeSet, waitForStack } from './util/cloudformation'; @@ -54,6 +54,12 @@ export interface DeployStackOptions { * @default - no additional parameters will be passed to the template */ parameters?: { [name: string]: string | undefined }; + + /** + * Deploy even if the deployed template is identical to the one we are about to deploy. + * @default false + */ + force?: boolean; } const LARGE_TEMPLATE_SIZE_KB = 50; @@ -64,6 +70,28 @@ export async function deployStack(options: DeployStackOptions): Promise { + const stackId = await getStackId(cfn, stackName); + if (!stackId) { + return undefined; + } + + const template = await readCurrentTemplate(cfn, stackName); + return { stackId, template }; +} + +export async function readCurrentTemplate(cfn: aws.CloudFormation, stackName: string) { + try { + const response = await cfn.getTemplate({ StackName: stackName, TemplateStage: 'Original' }).promise(); + return (response.TemplateBody && deserializeStructure(response.TemplateBody)) || {}; + } catch (e) { + if (e.code === 'ValidationError' && e.message === `Stack with id ${stackName} does not exist`) { + return {}; + } else { + throw e; + } + } +} + +async function getStackId(cfn: aws.CloudFormation, stackName: string): Promise { + try { + const stacks = await cfn.describeStacks({ StackName: stackName }).promise(); + if (!stacks.Stacks) { + return undefined; + } + if (stacks.Stacks.length !== 1) { + return undefined; + } + + return stacks.Stacks[0].StackId!; + + } catch (e) { + if (e.message.includes('does not exist')) { + return undefined; + } + throw e; + } +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/api/deployment-target.ts b/packages/aws-cdk/lib/api/deployment-target.ts index 1c4cd453a4bce..f5c707b63ea6f 100644 --- a/packages/aws-cdk/lib/api/deployment-target.ts +++ b/packages/aws-cdk/lib/api/deployment-target.ts @@ -1,9 +1,8 @@ import { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; import { Tag } from "../api/cxapp/stacks"; import { debug } from '../logging'; -import { deserializeStructure } from '../serialize'; import { Mode } from './aws-auth/credentials'; -import { deployStack, DeployStackResult } from './deploy-stack'; +import { deployStack, DeployStackResult, readCurrentTemplate } from './deploy-stack'; import { loadToolkitInfo } from './toolkit-info'; import { ISDK } from './util/sdk'; @@ -31,6 +30,12 @@ export interface DeployStackOptions { reuseAssets?: string[]; tags?: Tag[]; execute?: boolean; + + /** + * Force deployment, even if the deployed template is identical to the one we are about to deploy. + * @default false deployment will be skipped if the template is identical + */ + force?: boolean; } export interface ProvisionerProps { @@ -49,18 +54,8 @@ export class CloudFormationDeploymentTarget implements IDeploymentTarget { public async readCurrentTemplate(stack: CloudFormationStackArtifact): Promise