Skip to content

Commit

Permalink
feat(cli): faster "no-op" deployments (aws#6346)
Browse files Browse the repository at this point in the history
* 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 aws#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 aws#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 aws#6216

* Add alias -f for —force

* Add integration test

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
Elad Ben-Israel and mergify[bot] authored Feb 19, 2020
1 parent 1847d92 commit d4a132b
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 22 deletions.
5 changes: 5 additions & 0 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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':
Expand Down
76 changes: 72 additions & 4 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -64,6 +70,28 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
throw new Error(`The stack ${options.stack.displayName} does not have an environment`);
}

const cfn = await options.sdk.cloudFormation(options.stack.environment.account, options.stack.environment.region, Mode.ForWriting);
const deployName = options.deployName || options.stack.stackName;

if (!options.force) {
debug(`checking if we can skip this stack based on the currently deployed template (use --force to override)`);
const deployed = await getDeployedTemplate(cfn, deployName);
if (deployed && JSON.stringify(options.stack.template) === JSON.stringify(deployed.template)) {
debug(`${deployName}: no change in template, skipping (use --force to override)`);
return {
noOp: true,
outputs: await getStackOutputs(cfn, deployName),
stackArn: deployed.stackId,
stackArtifact: options.stack
};
} else {
debug(`${deployName}: template changed, deploying...`);
}
}

// bail out if the current template is exactly the same as the one we are about to deploy
// in cdk-land, this means nothing changed because assets (and therefore nested stacks) are immutable.

const params = await prepareAssets(options.stack, options.toolkitInfo, options.reuseAssets);

// add passed CloudFormation parameters
Expand All @@ -76,11 +104,8 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
}
}

const deployName = options.deployName || options.stack.stackName;

const executionId = uuid.v4();

const cfn = await options.sdk.cloudFormation(options.stack.environment.account, options.stack.environment.region, Mode.ForWriting);
const bodyParameter = await makeBodyParameter(options.stack, options.toolkitInfo);

if (await stackFailedCreating(cfn, deployName)) {
Expand Down Expand Up @@ -212,3 +237,46 @@ export async function destroyStack(options: DestroyStackOptions) {
}
return;
}

async function getDeployedTemplate(cfn: aws.CloudFormation, stackName: string): Promise<{ template: any, stackId: string } | undefined> {
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<string | undefined> {
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;
}
}
24 changes: 10 additions & 14 deletions packages/aws-cdk/lib/api/deployment-target.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 {
Expand All @@ -49,18 +54,8 @@ export class CloudFormationDeploymentTarget implements IDeploymentTarget {

public async readCurrentTemplate(stack: CloudFormationStackArtifact): Promise<Template> {
debug(`Reading existing template for stack ${stack.displayName}.`);

const cfn = await this.aws.cloudFormation(stack.environment.account, stack.environment.region, Mode.ForReading);
try {
const response = await cfn.getTemplate({ StackName: stack.stackName }).promise();
return (response.TemplateBody && deserializeStructure(response.TemplateBody)) || {};
} catch (e) {
if (e.code === 'ValidationError' && e.message === `Stack with id ${stack.stackName} does not exist`) {
return {};
} else {
throw e;
}
}
return readCurrentTemplate(cfn, stack.stackName);
}

public async deployStack(options: DeployStackOptions): Promise<DeployStackResult> {
Expand All @@ -75,7 +70,8 @@ export class CloudFormationDeploymentTarget implements IDeploymentTarget {
reuseAssets: options.reuseAssets,
toolkitInfo,
tags: options.tags,
execute: options.execute
execute: options.execute,
force: options.force
});
}
}
9 changes: 8 additions & 1 deletion packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ export class CdkToolkit {
reuseAssets: options.reuseAssets,
notificationArns: options.notificationArns,
tags,
execute: options.execute
execute: options.execute,
force: options.force
});

const message = result.noOp
Expand Down Expand Up @@ -308,6 +309,12 @@ export interface DeployOptions {
* @default true
*/
execute?: boolean;

/**
* Always deploy, even if templates are identical.
* @default false
*/
force?: boolean;
}

export interface DestroyOptions {
Expand Down
Loading

0 comments on commit d4a132b

Please sign in to comment.