Skip to content

Commit

Permalink
Introduce the HotswappableChange abstraction.
Browse files Browse the repository at this point in the history
  • Loading branch information
skinny85 committed Jul 27, 2021
1 parent 3c62bfc commit 7b50f65
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 170 deletions.
200 changes: 30 additions & 170 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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 { ListStackResources } from './hotswap/common';
import { isHotswappableLambdaFunctionChange, LambdaFunctionHotswappableResource, LambdaFunctionResource } from './hotswap/lambda-functions';
import { CloudFormationStack } from './util/cloudformation';
import { evaluateCfn } from './util/cloudformation/evaluate-cfn';

/**
* Perform a hotswap deployment,
Expand Down Expand Up @@ -58,185 +60,43 @@ function findAllHotswappableChanges(
return foundNonHotswappableChange ? undefined : hotswappableResources;
}

function isHotswappableLambdaFunctionChange(
logicalId: string, change: cfn_diff.ResourceDifference, assetParamsWithEnv: { [key: string]: string },
): LambdaFunctionResource | boolean {
const lambdaCodeChange = isLambdaFunctionCodeOnlyChange(change, assetParamsWithEnv);
if (typeof lambdaCodeChange === 'boolean') {
return lambdaCodeChange;
} else {
// verify that the Asset changed - otherwise,
// it's a code-only change,
// but not to an asset
if (!assetMetadataChanged(change)) {
return false;
}

let functionPhysicalName: string | undefined;
try {
functionPhysicalName = stringifyPotentialCfnExpression(change.newValue?.Properties?.FunctionName, assetParamsWithEnv);
} catch (e) {
// It's possible we can't evaluate the function's name -
// for example, it can use a Ref to a different resource,
// which we wouldn't have in `assetParamsWithEnv`.
// That's fine though - ignore any errors,
// and treat this case the same way as if the name wasn't provided at all,
// which means it will be looked up using the listStackResources() call
// by the later phase (which actually does the Lambda function update)
functionPhysicalName = undefined;
}

return {
logicalId,
physicalName: functionPhysicalName,
code: lambdaCodeChange,
};
}
}

function isLambdaFunctionCodeOnlyChange(
change: cfn_diff.ResourceDifference, assetParamsWithEnv: { [key: string]: string },
): LambdaFunctionCode | boolean {
if (!change.newValue) {
return false;
}
const newResourceType = change.newValue.Type;
// Ignore Metadata changes
if (newResourceType === 'AWS::CDK::Metadata') {
return true;
}
// The only other resource change we should see is a Lambda function
if (newResourceType !== 'AWS::Lambda::Function') {
return false;
}
if (change.oldValue?.Type == null) {
// this means this is a brand-new Lambda function -
// obviously, we can't short-circuit that!
return false;
}

/*
* On first glance, we would want to initialize these using the "previous" values (change.oldValue),
* in case only one of them changed, like the key, and the Bucket stayed the same.
* However, that actually fails for old-style synthesis, which uses CFN Parameters!
* Because the names of the Parameters depend on the hash of the Asset,
* the Parameters used for the "old" values no longer exist in `assetParams` at this point,
* which means we don't have the correct values available to evaluate the CFN expression with.
* Fortunately, the diff will always include both the s3Bucket and s3Key parts of the Lambda's Code property,
* even if only one of them was actually changed,
* which means we don't need the "old" values at all, and we can safely initialize these with just `''`.
*/
let s3Bucket = '', s3Key = '';
let foundCodeDifference = false;
// Make sure only the code in the Lambda function changed
const propertyUpdates = change.propertyUpdates;
for (const updatedPropName in propertyUpdates) {
const updatedProp = propertyUpdates[updatedPropName];
if (updatedProp.newValue === undefined) {
return false;
}
for (const newPropName in updatedProp.newValue) {
switch (newPropName) {
case 'S3Bucket':
foundCodeDifference = true;
s3Bucket = stringifyPotentialCfnExpression(updatedProp.newValue[newPropName], assetParamsWithEnv);
break;
case 'S3Key':
foundCodeDifference = true;
s3Key = stringifyPotentialCfnExpression(updatedProp.newValue[newPropName], assetParamsWithEnv);
break;
default:
return false;
}
}
}

return foundCodeDifference
? {
s3Bucket,
s3Key,
}
: true;
}

/**
* 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.
*/
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);
}

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

export interface LambdaFunctionCode {
readonly s3Bucket: string;
readonly s3Key: string;
}

export interface LambdaFunctionResource {
readonly logicalId: string;
readonly physicalName?: string;
readonly code: LambdaFunctionCode;
}

async function applyAllHotswappableChanges(
sdk: ISDK, stackArtifact: cxapi.CloudFormationStackArtifact, hotswappableChanges: LambdaFunctionResource[],
): 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.
let stackResources: AWS.CloudFormation.StackResourceSummary[] = [];
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);

for (const change of hotswappableChanges) {
let functionPhysicalName: string;
if (change.physicalName) {
functionPhysicalName = change.physicalName;
} else {
if (stackResources.length === 0) {
stackResources = await getStackResources(sdk, stackArtifact.stackName);
}
await LambdaFunctionHotswappableResource.hotswap(change, sdk, listStackResources);
}
}

const foundFunctionName = stackResources
.find(resSummary => resSummary.LogicalResourceId === change.logicalId)
?.PhysicalResourceId;
if (!foundFunctionName) {
// if we couldn't find the function in the current stack, we can't update it
continue;
}
functionPhysicalName = foundFunctionName;
}
class LazyListStackResources implements ListStackResources {
private stackResources: CloudFormation.StackResourceSummary[] | undefined;

await sdk.lambda().updateFunctionCode({
FunctionName: functionPhysicalName,
S3Bucket: change.code.s3Bucket,
S3Key: change.code.s3Key,
}).promise();
constructor(private readonly sdk: ISDK, private readonly stackName: string) {
}
}

async function getStackResources(sdk: ISDK, stackName: string): Promise<AWS.CloudFormation.StackResourceSummary[]> {
const ret = new Array<AWS.CloudFormation.StackResourceSummary>();
let nextToken: string | undefined;
do {
const stackResourcesResponse = await sdk.cloudFormation().listStackResources({
StackName: stackName,
NextToken: nextToken,
}).promise();
ret.push(...(stackResourcesResponse.StackResourceSummaries ?? []));
nextToken = stackResourcesResponse.NextToken;
} while (nextToken);
return ret;
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;
}
}
39 changes: 39 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 short-circuited.
*
* @param T the type of the change that is passed
*/
export interface HotswappableChange<T> {
hotswap(t: T, sdk: ISDK, stackResources: ListStackResources): Promise<void>;
}

/**
* 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 7b50f65

Please sign in to comment.