Skip to content

Commit

Permalink
feat(cli): hotswap deployments (aws#15748)
Browse files Browse the repository at this point in the history
This is the first PR implementing the ["Accelerated personal deployments" RFC](https://github.com/aws/aws-cdk-rfcs/blob/master/text/0001-cdk-update.md).

It adds a (boolean) `--hotswap` flag to the `deploy` command that attempts to perform a short-circuit deployment, updating the resource directly, and skipping CloudFormation.
If we detect that the current change cannot be short-circuited
(because it contains an infrastructure change to the CDK code, most likely),
we fall back on performing a full CloudFormation deployment,
same as if `cdk deploy` was called without the `--hotswap` flag.

In this PR, the new switch supports only Lambda functions. Later PRs will add support for new resource types.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
skinny85 committed Sep 2, 2021
1 parent dbfebb4 commit 6e55c95
Show file tree
Hide file tree
Showing 18 changed files with 787 additions and 18 deletions.
6 changes: 3 additions & 3 deletions packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts
Expand Up @@ -282,7 +282,7 @@ export class Difference<ValueType> implements IDifference<ValueType> {
* @param oldValue the old value, cannot be equal (to the sense of +deepEqual+) to +newValue+.
* @param newValue the new value, cannot be equal (to the sense of +deepEqual+) to +oldValue+.
*/
constructor(public readonly oldValue: ValueType | undefined, public readonly newValue: ValueType | undefined) {
constructor(public readonly oldValue: ValueType | undefined, public readonly newValue: ValueType | undefined) {
if (oldValue === undefined && newValue === undefined) {
throw new AssertionError({ message: 'oldValue and newValue are both undefined!' });
}
Expand All @@ -309,7 +309,7 @@ export class Difference<ValueType> implements IDifference<ValueType> {
export class PropertyDifference<ValueType> extends Difference<ValueType> {
public readonly changeImpact?: ResourceImpact;

constructor(oldValue: ValueType | undefined, newValue: ValueType | undefined, args: { changeImpact?: ResourceImpact }) {
constructor(oldValue: ValueType | undefined, newValue: ValueType | undefined, args: { changeImpact?: ResourceImpact }) {
super(oldValue, newValue);
this.changeImpact = args.changeImpact;
}
Expand Down Expand Up @@ -506,7 +506,7 @@ export class ResourceDifference implements IDifference<Resource> {

constructor(
public readonly oldValue: Resource | undefined,
public readonly newValue: Resource | undefined,
public readonly newValue: Resource | undefined,
args: {
resourceType: { oldType?: string, newType?: string },
propertyDiffs: { [key: string]: PropertyDifference<any> },
Expand Down
36 changes: 36 additions & 0 deletions packages/aws-cdk/README.md
Expand Up @@ -334,6 +334,42 @@ execute.
$ cdk deploy --no-execute --change-set-name MyChangeSetName
```

#### Hotswap deployments for faster development

You can pass the `--hotswap` flag to the `deploy` command:

```console
$ cdk deploy --hotswap [StackNames]
```

This will attempt to perform a faster, short-circuit deployment if possible
(for example, if you only changed the code of a Lambda function in your CDK app,
but nothing else in your CDK code),
skipping CloudFormation, and updating the affected resources directly.
If the tool detects that the change does not support hotswapping,
it will fall back and perform a full CloudFormation deployment,
exactly like `cdk deploy` does without the `--hotswap` flag.

Passing this option to `cdk deploy` will make it use your current AWS credentials to perform the API calls -
it will not assume the Roles from your bootstrap stack,
even if the `@aws-cdk/core:newStyleStackSynthesis` feature flag is set to `true`
(as those Roles do not have the necessary permissions to update AWS resources directly, without using CloudFormation).
For that reason, make sure that your credentials are for the same AWS account that the Stack(s)
you are performing the hotswap deployment for belong to,
and that you have the necessary IAM permissions to update the resources that are being deployed.

Hotswapping is currently supported for the following changes
(additional changes will be supported in the future):

- Code asset changes of AWS Lambda functions.

**⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments.
For this reason, only use it for development purposes.
**Never use this flag for your production deployments**!

**⚠ Note #2**: This command is considered experimental,
and might have breaking changes in the future.

### `cdk destroy`

Deletes a stack from it's environment. This will cause the resources in the stack to be destroyed (unless they were
Expand Down
12 changes: 10 additions & 2 deletions packages/aws-cdk/bin/cdk.ts
Expand Up @@ -105,10 +105,17 @@ async function parseCommandLineArguments() {
.option('outputs-file', { type: 'string', alias: 'O', desc: 'Path to file where stack outputs will be written as JSON', requiresArg: true })
.option('previous-parameters', { type: 'boolean', default: true, desc: 'Use previous values for existing parameters (you must specify all parameters on every deployment if this is disabled)' })
.option('progress', { type: 'string', choices: [StackActivityProgress.BAR, StackActivityProgress.EVENTS], desc: 'Display mode for stack activity events' })
.option('rollback', { type: 'boolean', default: true, desc: 'Rollback stack to stable state on failure (iterate more rapidly with --no-rollback or -R)' })
.option('rollback', { type: 'boolean', desc: 'Rollback stack to stable state on failure (iterate more rapidly with --no-rollback or -R)' })
// Hack to get '-R' as an alias for '--no-rollback', suggested by: https://github.com/yargs/yargs/issues/1729
.option('R', { type: 'boolean', hidden: true })
.middleware(yargsNegativeAlias('R', 'rollback'), true),
.middleware(yargsNegativeAlias('R', 'rollback'), true)
.option('hotswap', {
type: 'boolean',
desc: "Attempts to perform a 'hotswap' deployment, " +
'which skips CloudFormation and updates the resources directly, ' +
'and falls back to a full deployment if that is not possible. ' +
'Do not use this in production environments',
}),
)
.command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs
.option('all', { type: 'boolean', default: false, desc: 'Destroy all available stacks' })
Expand Down Expand Up @@ -324,6 +331,7 @@ async function initCommandLine() {
progress: configuration.settings.get(['progress']),
ci: args.ci,
rollback: configuration.settings.get(['rollback']),
hotswap: args.hotswap,
});

case 'destroy':
Expand Down
5 changes: 5 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/sdk.ts
Expand Up @@ -22,6 +22,7 @@ export interface ISDK {
*/
currentAccount(): Promise<Account>;

lambda(): AWS.Lambda;
cloudFormation(): AWS.CloudFormation;
ec2(): AWS.EC2;
ssm(): AWS.SSM;
Expand Down Expand Up @@ -83,6 +84,10 @@ export class SDK implements ISDK {
this.currentRegion = region;
}

public lambda(): AWS.Lambda {
return this.wrapServiceErrorHandling(new AWS.Lambda(this.config));
}

public cloudFormation(): AWS.CloudFormation {
return this.wrapServiceErrorHandling(new AWS.CloudFormation({
...this.config,
Expand Down
10 changes: 10 additions & 0 deletions packages/aws-cdk/lib/api/cloudformation-deployments.ts
Expand Up @@ -136,6 +136,15 @@ export interface DeployStackOptions {
* @default true
*/
readonly rollback?: boolean;

/*
* Whether to perform a 'hotswap' deployment.
* A 'hotswap' deployment will attempt to short-circuit CloudFormation
* and update the affected resources like Lambda functions directly.
*
* @default - false (do not perform a 'hotswap' deployment)
*/
readonly hotswap?: boolean;
}

export interface DestroyStackOptions {
Expand Down Expand Up @@ -212,6 +221,7 @@ export class CloudFormationDeployments {
progress: options.progress,
ci: options.ci,
rollback: options.rollback,
hotswap: options.hotswap,
});
}

Expand Down
46 changes: 41 additions & 5 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Expand Up @@ -9,8 +9,12 @@ import { AssetManifestBuilder } from '../util/asset-manifest-builder';
import { publishAssets } from '../util/asset-publishing';
import { contentHash } from '../util/content-hash';
import { ISDK, SdkProvider } from './aws-auth';
import { tryHotswapDeployment } from './hotswap-deployments';
import { ToolkitInfo } from './toolkit-info';
import { changeSetHasNoChanges, CloudFormationStack, TemplateParameters, waitForChangeSet, waitForStackDeploy, waitForStackDelete } from './util/cloudformation';
import {
changeSetHasNoChanges, CloudFormationStack, TemplateParameters, waitForChangeSet,
waitForStackDeploy, waitForStackDelete, ParameterValues,
} from './util/cloudformation';
import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor';

// We need to map regions to domain suffixes, and the SDK already has a function to do this.
Expand Down Expand Up @@ -182,6 +186,15 @@ export interface DeployStackOptions {
* @default true
*/
readonly rollback?: boolean;

/*
* Whether to perform a 'hotswap' deployment.
* A 'hotswap' deployment will attempt to short-circuit CloudFormation
* and update the affected resources like Lambda functions directly.
*
* @default - false (do not perform a 'hotswap' deployment)
*/
readonly hotswap?: boolean;
}

const LARGE_TEMPLATE_SIZE_KB = 50;
Expand Down Expand Up @@ -233,12 +246,32 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
debug(`${deployName}: deploying...`);
}

const executionId = uuid.v4();
const bodyParameter = await makeBodyParameter(stackArtifact, options.resolvedEnvironment, legacyAssets, options.toolkitInfo);

await publishAssets(legacyAssets.toManifest(stackArtifact.assembly.directory), options.sdkProvider, stackEnv);

const changeSetName = options.changeSetName || 'cdk-deploy-change-set';
if (options.hotswap) {
// attempt to short-circuit the deployment if possible
const hotswapDeploymentResult = await tryHotswapDeployment(options.sdkProvider, assetParams, cloudFormationStack, stackArtifact);
if (hotswapDeploymentResult) {
return hotswapDeploymentResult;
}
// could not short-circuit the deployment, perform a full CFN deploy instead
print('Could not perform a hotswap deployment, as the stack %s contains non-Asset changes', stackArtifact.displayName);
print('Falling back to doing a full deployment');
}

return prepareAndExecuteChangeSet(options, cloudFormationStack, stackArtifact, stackParams, bodyParameter);
}

async function prepareAndExecuteChangeSet(
options: DeployStackOptions, cloudFormationStack: CloudFormationStack,
stackArtifact: cxapi.CloudFormationStackArtifact, stackParams: ParameterValues, bodyParameter: TemplateBodyParameter,
): Promise<DeployStackResult> {
const cfn = options.sdk.cloudFormation();
const deployName = options.deployName ?? stackArtifact.stackName;

const changeSetName = options.changeSetName ?? 'cdk-deploy-change-set';
if (cloudFormationStack.exists) {
//Delete any existing change sets generated by CDK since change set names must be unique.
//The delete request is successful as long as the stack exists (even if the change set does not exist).
Expand All @@ -250,6 +283,7 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt

debug(`Attempting to create ChangeSet with name ${changeSetName} to ${update ? 'update' : 'create'} stack ${deployName}`);
print('%s: creating CloudFormation changeset...', colors.bold(deployName));
const executionId = uuid.v4();
const changeSet = await cfn.createChangeSet({
StackName: deployName,
ChangeSetName: changeSetName,
Expand Down Expand Up @@ -290,9 +324,11 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
if (execute) {
debug('Initiating execution of changeset %s on stack %s', changeSet.Id, deployName);

const shouldDisableRollback = (options.rollback === undefined && options.hotswap === true) || options.rollback === false;
// Do a bit of contortions to only pass the `DisableRollback` flag if it's true. That way,
// CloudFormation won't balk at the unrecognized option in regions where the feature is not available yet.
const disableRollback = options.rollback === false ? { DisableRollback: true } : undefined;
const disableRollback = shouldDisableRollback ? { DisableRollback: true } : undefined;

await cfn.executeChangeSet({ StackName: deployName, ChangeSetName: changeSetName, ...disableRollback }).promise();

// eslint-disable-next-line max-len
Expand Down Expand Up @@ -528,4 +564,4 @@ function restUrlFromManifest(url: string, environment: cxapi.Environment): strin

const urlSuffix: string = regionUtil.getEndpointSuffix(environment.region);
return `https://s3.${environment.region}.${urlSuffix}/${bucketName}/${objectKey}`;
}
}
100 changes: 100 additions & 0 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
@@ -0,0 +1,100 @@
import * as cfn_diff from '@aws-cdk/cloudformation-diff';
import * as cxapi from '@aws-cdk/cx-api';
import { CloudFormation } from 'aws-sdk';
import { ISDK, Mode, SdkProvider } from './aws-auth';
import { DeployStackResult } from './deploy-stack';
import { ChangeHotswapImpact, HotswapOperation, ListStackResources } from './hotswap/common';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { CloudFormationStack } from './util/cloudformation';

/**
* Perform a hotswap deployment,
* short-circuiting CloudFormation if possible.
* If it's not possible to short-circuit the deployment
* (because the CDK Stack contains changes that cannot be deployed without CloudFormation),
* returns `undefined`.
*/
export async function tryHotswapDeployment(
sdkProvider: SdkProvider, assetParams: { [key: string]: string },
cloudFormationStack: CloudFormationStack, stackArtifact: cxapi.CloudFormationStackArtifact,
): Promise<DeployStackResult | undefined> {
const currentTemplate = await cloudFormationStack.template();
const stackChanges = cfn_diff.diffTemplate(currentTemplate, stackArtifact.template);

// resolve the environment, so we can substitute things like AWS::Region in CFN expressions
const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment);
const hotswappableChanges = findAllHotswappableChanges(stackChanges, {
...assetParams,
'AWS::Region': resolvedEnv.region,
'AWS::AccountId': resolvedEnv.account,
});
if (!hotswappableChanges) {
// this means there were changes to the template that cannot be short-circuited
return undefined;
}

// create a new SDK using the CLI credentials, because the default one will not work for new-style synthesis -
// it assumes the bootstrap deploy Role, which doesn't have permissions to update Lambda functions
const sdk = await sdkProvider.forEnvironment(resolvedEnv, Mode.ForWriting);
// apply the short-circuitable changes
await applyAllHotswappableChanges(sdk, stackArtifact, hotswappableChanges);

return { noOp: hotswappableChanges.length === 0, stackArn: cloudFormationStack.stackId, outputs: cloudFormationStack.outputs, stackArtifact };
}

function findAllHotswappableChanges(
stackChanges: cfn_diff.TemplateDiff, assetParamsWithEnv: { [key: string]: string },
): HotswapOperation[] | undefined {
const hotswappableResources = new Array<HotswapOperation>();
let foundNonHotswappableChange = false;
stackChanges.resources.forEachDifference((logicalId: string, change: cfn_diff.ResourceDifference) => {
const lambdaFunctionShortCircuitChange = isHotswappableLambdaFunctionChange(logicalId, change, assetParamsWithEnv);
if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
foundNonHotswappableChange = true;
} else if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.IRRELEVANT) {
// empty 'if' just for flow-aware typing to kick in...
} else {
hotswappableResources.push(lambdaFunctionShortCircuitChange);
}
});
return foundNonHotswappableChange ? undefined : hotswappableResources;
}

async function applyAllHotswappableChanges(
sdk: ISDK, stackArtifact: cxapi.CloudFormationStackArtifact, hotswappableChanges: HotswapOperation[],
): Promise<void[]> {
// The current resources of the Stack.
// We need them to figure out the physical name of a function in case it wasn't specified by the user.
// We fetch it lazily, to save a service call, in case all updated Lambdas have their names set.
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);

return Promise.all(hotswappableChanges.map(hotswapOperation => hotswapOperation.apply(sdk, listStackResources)));
}

class LazyListStackResources implements ListStackResources {
private stackResources: CloudFormation.StackResourceSummary[] | undefined;

constructor(private readonly sdk: ISDK, private readonly stackName: string) {
}

async listStackResources(): Promise<CloudFormation.StackResourceSummary[]> {
if (this.stackResources === undefined) {
this.stackResources = await this.getStackResource();
}
return this.stackResources;
}

private async getStackResource(): Promise<CloudFormation.StackResourceSummary[]> {
const ret = new Array<CloudFormation.StackResourceSummary>();
let nextToken: string | undefined;
do {
const stackResourcesResponse = await this.sdk.cloudFormation().listStackResources({
StackName: this.stackName,
NextToken: nextToken,
}).promise();
ret.push(...(stackResourcesResponse.StackResourceSummaries ?? []));
nextToken = stackResourcesResponse.NextToken;
} while (nextToken);
return ret;
}
}
57 changes: 57 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/common.ts
@@ -0,0 +1,57 @@
import * as cfn_diff from '@aws-cdk/cloudformation-diff';
import { CloudFormation } from 'aws-sdk';
import { ISDK } from '../aws-auth';
import { evaluateCfn } from '../util/cloudformation/evaluate-cfn';

export interface ListStackResources {
listStackResources(): Promise<CloudFormation.StackResourceSummary[]>;
}

/**
* An interface that represents a change that can be deployed in a short-circuit manner.
*/
export interface HotswapOperation {
apply(sdk: ISDK, stackResources: ListStackResources): Promise<any>;
}

/**
* An enum that represents the result of detection whether a given change can be hotswapped.
*/
export enum ChangeHotswapImpact {
/**
* This result means that the given change cannot be hotswapped,
* and requires a full deployment.
*/
REQUIRES_FULL_DEPLOYMENT = 'requires-full-deployment',

/**
* This result means that the given change can be safely be ignored when determining
* whether the given Stack can be hotswapped or not
* (for example, it's a change to the CDKMetadata resource).
*/
IRRELEVANT = 'irrelevant',
}

export type ChangeHotswapResult = HotswapOperation | ChangeHotswapImpact;

/**
* For old-style synthesis which uses CFN Parameters,
* the Code properties can have the values of complex CFN expressions.
* For new-style synthesis of env-agnostic stacks,
* the Fn::Sub expression is used for the Asset bucket.
* Evaluate the CFN expressions to concrete string values which we need for the
* updateFunctionCode() service call.
*/
export function stringifyPotentialCfnExpression(value: any, assetParamsWithEnv: { [key: string]: string }): string {
// if we already have a string, nothing to do
if (value == null || typeof value === 'string') {
return value;
}

// otherwise, we assume this is a CloudFormation expression that we need to evaluate
return evaluateCfn(value, assetParamsWithEnv);
}

export function assetMetadataChanged(change: cfn_diff.ResourceDifference): boolean {
return !!change.newValue?.Metadata['aws:asset:path'];
}

0 comments on commit 6e55c95

Please sign in to comment.