Skip to content

Commit

Permalink
chore(cli): add more capabilities to the hotswap CFN evaluate sub-sys…
Browse files Browse the repository at this point in the history
…tem (#16696)

The current functionality we use for evaluating CloudFormation in the hotswap part of the CLI is very limited:
only allows substituting the values of parameters used for Assets.
That's not good enough when doing substitutions for StepFunctions State Machines from
[this PR](#16489), for example.

Enhance the capabilities of the CFN eval sub-system by introducing a new class,
`CloudFormationExecutableTemplate`, that allows resolving references to resources inside the template.

----

*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 authored and njlynch committed Oct 11, 2021
1 parent 6997557 commit 7fb8c32
Show file tree
Hide file tree
Showing 7 changed files with 617 additions and 193 deletions.
18 changes: 13 additions & 5 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { publishAssets } from '../util/asset-publishing';
import { contentHash } from '../util/content-hash';
import { ISDK, SdkProvider } from './aws-auth';
import { tryHotswapDeployment } from './hotswap-deployments';
import { CfnEvaluationException } from './hotswap/evaluate-cloudformation-template';
import { ToolkitInfo } from './toolkit-info';
import {
changeSetHasNoChanges, CloudFormationStack, TemplateParameters, waitForChangeSet,
Expand Down Expand Up @@ -252,12 +253,19 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt

if (options.hotswap) {
// attempt to short-circuit the deployment if possible
const hotswapDeploymentResult = await tryHotswapDeployment(options.sdkProvider, assetParams, cloudFormationStack, stackArtifact);
if (hotswapDeploymentResult) {
return hotswapDeploymentResult;
try {
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);
} catch (e) {
if (!(e instanceof CfnEvaluationException)) {
throw e;
}
print('Could not perform a hotswap deployment, because the CloudFormation template could not be resolved: %s', e.message);
}
// 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');
}

Expand Down
84 changes: 49 additions & 35 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ 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 { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, ListStackResources } from './hotswap/common';
import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { CloudFormationStack } from './util/cloudformation';

Expand All @@ -18,57 +19,70 @@ 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,
// 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);
// The current resources of the Stack.
// We need them to figure out the physical name of a resource in case it wasn't specified by the user.
// We fetch it lazily, to save a service call, in case all hotswapped resources have their physical names set.
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);
const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
stackArtifact,
parameters: assetParams,
account: resolvedEnv.account,
region: resolvedEnv.region,
// ToDo make this better:
partition: 'aws',
// ToDo make this better:
urlSuffix: 'amazonaws.com',
listStackResources,
});

const currentTemplate = await cloudFormationStack.template();
const stackChanges = cfn_diff.diffTemplate(currentTemplate, stackArtifact.template);
const hotswappableChanges = await findAllHotswappableChanges(stackChanges, evaluateCfnTemplate);
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);
await applyAllHotswappableChanges(sdk, 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);
async function findAllHotswappableChanges(
stackChanges: cfn_diff.TemplateDiff, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<HotswapOperation[] | undefined> {
const promises = new Array<Promise<ChangeHotswapResult>>();
stackChanges.resources.forEachDifference(async (logicalId: string, change: cfn_diff.ResourceDifference) => {
promises.push(isHotswappableLambdaFunctionChange(logicalId, change, evaluateCfnTemplate));
});
return Promise.all(promises).then(hotswapDetectionResults => {
const hotswappableResources = new Array<HotswapOperation>();
let foundNonHotswappableChange = false;
for (const lambdaFunctionShortCircuitChange of hotswapDetectionResults) {
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;
});
return foundNonHotswappableChange ? undefined : hotswappableResources;
}

async function applyAllHotswappableChanges(
sdk: ISDK, stackArtifact: cxapi.CloudFormationStackArtifact, hotswappableChanges: HotswapOperation[],
sdk: ISDK, 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)));
return Promise.all(hotswappableChanges.map(hotswapOperation => {
return hotswapOperation.apply(sdk);
}));
}

class LazyListStackResources implements ListStackResources {
Expand All @@ -79,12 +93,12 @@ class LazyListStackResources implements ListStackResources {

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

private async getStackResource(): Promise<CloudFormation.StackResourceSummary[]> {
private async getStackResources(): Promise<CloudFormation.StackResourceSummary[]> {
const ret = new Array<CloudFormation.StackResourceSummary>();
let nextToken: string | undefined;
do {
Expand Down
21 changes: 1 addition & 20 deletions packages/aws-cdk/lib/api/hotswap/common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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[]>;
Expand All @@ -11,7 +10,7 @@ export interface ListStackResources {
* 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>;
apply(sdk: ISDK): Promise<any>;
}

/**
Expand All @@ -34,24 +33,6 @@ export enum ChangeHotswapImpact {

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'];
}
Loading

0 comments on commit 7fb8c32

Please sign in to comment.