Skip to content

Commit

Permalink
feat(cli): support for hotswapping Lambda Versions and Aliases (#18145)
Browse files Browse the repository at this point in the history
This extends the existing hotswapping support for Lambda Functions to also work for Versions and Aliases.

Implementation-wise, this required quite a bit of changes, as Versions are immutable in CloudFormation,
and the only way you can change them is by replacing them;
so, we had to add the notion of replacement changes to our hotswapping logic
(as, up to this point, they were simply a pair of "delete" and "add" changes,
which would result in hotswapping determining it can't proceed,
and falling back to a full CloudFormation deployment).

I also modified the main hotswapping algorithm:
now, a resource change is considered non-hotswappable if all detectors return `REQUIRES_FULL_DEPLOYMENT` for it;
if at least one detector returns `IRRELEVANT`, we ignore this change.
This allows us to get rid of the awkward `EmptyHotswapOperation` that we had to use before in these situations.

I also made a few small tweaks to the printing messages added in #18058:
I no longer prefix them with the name of the service,
as now hotswapping can affect different resource types, and it looked a bit awkward with that prefix present.

Fixes #17043

----

*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 Dec 28, 2021
1 parent e2c8063 commit 13d77b7
Show file tree
Hide file tree
Showing 11 changed files with 407 additions and 61 deletions.
3 changes: 2 additions & 1 deletion packages/aws-cdk/README.md
Expand Up @@ -362,7 +362,8 @@ and that you have the necessary IAM permissions to update the resources that are
Hotswapping is currently supported for the following changes
(additional changes will be supported in the future):

- Code asset changes of AWS Lambda functions.
- Code asset and tag changes of AWS Lambda functions.
- AWS Lambda Versions and Aliases changes.
- Definition changes of AWS Step Functions State Machines.
- Container asset changes of AWS ECS Services.
- Website asset changes of AWS S3 Bucket Deployments.
Expand Down
99 changes: 85 additions & 14 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Expand Up @@ -61,11 +61,12 @@ export async function tryHotswapDeployment(
async function findAllHotswappableChanges(
stackChanges: cfn_diff.TemplateDiff, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<HotswapOperation[] | undefined> {
const resourceDifferences = getStackResourceDifferences(stackChanges);

let foundNonHotswappableChange = false;
const promises: Array<Array<Promise<ChangeHotswapResult>>> = [];

// gather the results of the detector functions
stackChanges.resources.forEachDifference((logicalId: string, change: cfn_diff.ResourceDifference) => {
for (const [logicalId, change] of Object.entries(resourceDifferences)) {
const resourceHotswapEvaluation = isCandidateForHotswapping(change);

if (resourceHotswapEvaluation === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
Expand All @@ -81,17 +82,16 @@ async function findAllHotswappableChanges(
isHotswappableCodeBuildProjectChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
]);
}
});
}

// resolve all detector results
const changesDetectionResults: Array<Array<ChangeHotswapResult>> = [];
for (const detectorResultPromises of promises) {
const hotswapDetectionResults = await Promise.all(detectorResultPromises);
changesDetectionResults.push(hotswapDetectionResults);
}

const hotswappableResources = new Array<HotswapOperation>();

// resolve all detector results
for (const hotswapDetectionResults of changesDetectionResults) {
const perChangeHotswappableResources = new Array<HotswapOperation>();

Expand All @@ -107,23 +107,94 @@ async function findAllHotswappableChanges(
continue;
}

// no hotswappable changes found, so any REQUIRES_FULL_DEPLOYMENTs imply a non-hotswappable change
for (const result of hotswapDetectionResults) {
if (result === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
foundNonHotswappableChange = true;
}
// no hotswappable changes found, so at least one IRRELEVANT means we can ignore this change;
// otherwise, all answers are REQUIRES_FULL_DEPLOYMENT, so this means we can't hotswap this change,
// and have to do a full deployment instead
if (!hotswapDetectionResults.some(hdr => hdr === ChangeHotswapImpact.IRRELEVANT)) {
foundNonHotswappableChange = true;
}
// no REQUIRES_FULL_DEPLOYMENT implies that all results are IRRELEVANT
}

return foundNonHotswappableChange ? undefined : hotswappableResources;
}

/**
* Returns all changes to resources in the given Stack.
*
* @param stackChanges the collection of all changes to a given Stack
*/
function getStackResourceDifferences(stackChanges: cfn_diff.TemplateDiff): { [logicalId: string]: cfn_diff.ResourceDifference } {
// we need to collapse logical ID rename changes into one change,
// as they are represented in stackChanges as a pair of two changes: one addition and one removal
const allResourceChanges: { [logId: string]: cfn_diff.ResourceDifference } = stackChanges.resources.changes;
const allRemovalChanges = filterDict(allResourceChanges, resChange => resChange.isRemoval);
const allNonRemovalChanges = filterDict(allResourceChanges, resChange => !resChange.isRemoval);
for (const [logId, nonRemovalChange] of Object.entries(allNonRemovalChanges)) {
if (nonRemovalChange.isAddition) {
const addChange = nonRemovalChange;
// search for an identical removal change
const identicalRemovalChange = Object.entries(allRemovalChanges).find(([_, remChange]) => {
return changesAreForSameResource(remChange, addChange);
});
// if we found one, then this means this is a rename change
if (identicalRemovalChange) {
const [removedLogId, removedResourceChange] = identicalRemovalChange;
allNonRemovalChanges[logId] = makeRenameDifference(removedResourceChange, addChange);
// delete the removal change that forms the rename pair
delete allRemovalChanges[removedLogId];
}
}
}
// the final result are all of the remaining removal changes,
// plus all of the non-removal changes
// (we saved the rename changes in that object already)
return {
...allRemovalChanges,
...allNonRemovalChanges,
};
}

/** Filters an object with string keys based on whether the callback returns 'true' for the given value in the object. */
function filterDict<T>(dict: { [key: string]: T }, func: (t: T) => boolean): { [key: string]: T } {
return Object.entries(dict).reduce((acc, [key, t]) => {
if (func(t)) {
acc[key] = t;
}
return acc;
}, {} as { [key: string]: T });
}

/** Returns 'true' if a pair of changes is for the same resource. */
function changesAreForSameResource(oldChange: cfn_diff.ResourceDifference, newChange: cfn_diff.ResourceDifference): boolean {
return oldChange.oldResourceType === newChange.newResourceType &&
// this isn't great, but I don't want to bring in something like underscore just for this comparison
JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties);
}

function makeRenameDifference(
remChange: cfn_diff.ResourceDifference,
addChange: cfn_diff.ResourceDifference,
): cfn_diff.ResourceDifference {
return new cfn_diff.ResourceDifference(
// we have to fill in the old value, because otherwise this will be classified as a non-hotswappable change
remChange.oldValue,
addChange.newValue,
{
resourceType: {
oldType: remChange.oldResourceType,
newType: addChange.newResourceType,
},
propertyDiffs: (addChange as any).propertyDiffs,
otherDiffs: (addChange as any).otherDiffs,
},
);
}

/**
* returns `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if a resource was deleted, or a change that we cannot short-circuit occured.
* Returns `ChangeHotswapImpact.IRRELEVANT` if a change that does not impact shortcircuiting occured, such as a metadata change.
*/
export function isCandidateForHotswapping(change: cfn_diff.ResourceDifference): HotswappableChangeCandidate | ChangeHotswapImpact {
function isCandidateForHotswapping(change: cfn_diff.ResourceDifference): HotswappableChangeCandidate | ChangeHotswapImpact {
// a resource has been removed OR a resource has been added; we can't short-circuit that change
if (!change.newValue || !change.oldValue) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
Expand Down Expand Up @@ -156,12 +227,12 @@ async function applyHotswappableChange(sdk: ISDK, hotswapOperation: HotswapOpera

try {
for (const name of hotswapOperation.resourceNames) {
print(` ${ICON} hotswapping ${hotswapOperation.service}: %s`, colors.bold(name));
print(` ${ICON} %s`, colors.bold(name));
}
return await hotswapOperation.apply(sdk);
} finally {
for (const name of hotswapOperation.resourceNames) {
print(`${ICON} ${hotswapOperation.service}: %s %s`, colors.bold(name), colors.green('hotswapped!'));
print(`${ICON} %s %s`, colors.bold(name), colors.green('hotswapped!'));
}
sdk.removeCustomUserAgent(customUserAgent);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/api/hotswap/code-build-projects.ts
Expand Up @@ -51,7 +51,7 @@ class ProjectHotswapOperation implements HotswapOperation {
constructor(
private readonly updateProjectInput: AWS.CodeBuild.UpdateProjectInput,
) {
this.resourceNames = [updateProjectInput.name];
this.resourceNames = [`CodeBuild project '${updateProjectInput.name}'`];
}

public async apply(sdk: ISDK): Promise<any> {
Expand Down
3 changes: 2 additions & 1 deletion packages/aws-cdk/lib/api/hotswap/ecs-services.ts
Expand Up @@ -83,7 +83,8 @@ class EcsServiceHotswapOperation implements HotswapOperation {
private readonly taskDefinitionResource: any,
private readonly servicesReferencingTaskDef: EcsService[],
) {
this.resourceNames = servicesReferencingTaskDef.map(ecsService => ecsService.serviceArn.split('/')[2]);
this.resourceNames = servicesReferencingTaskDef.map(ecsService =>
`ECS Service '${ecsService.serviceArn.split('/')[2]}'`);
}

public async apply(sdk: ISDK): Promise<any> {
Expand Down
117 changes: 97 additions & 20 deletions packages/aws-cdk/lib/api/hotswap/lambda-functions.ts
@@ -1,5 +1,6 @@
import { flatMap } from '../../util';
import { ISDK } from '../aws-auth';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, establishResourcePhysicalName } from './common';
import { ChangeHotswapImpact, ChangeHotswapResult, establishResourcePhysicalName, HotswapOperation, HotswappableChangeCandidate } from './common';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

/**
Expand All @@ -11,25 +12,62 @@ import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-templa
export async function isHotswappableLambdaFunctionChange(
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
// if the change is for a Lambda Version,
// ignore it by returning an empty hotswap operation -
// we will publish a new version when we get to hotswapping the actual Function this Version points to, below
// (Versions can't be changed in CloudFormation anyway, they're immutable)
if (change.newValue.Type === 'AWS::Lambda::Version') {
return ChangeHotswapImpact.IRRELEVANT;
}

// we handle Aliases specially too
if (change.newValue.Type === 'AWS::Lambda::Alias') {
return checkAliasHasVersionOnlyChange(change);
}

const lambdaCodeChange = await isLambdaFunctionCodeOnlyChange(change, evaluateCfnTemplate);
if (typeof lambdaCodeChange === 'string') {
return lambdaCodeChange;
} else {
const functionName = await establishResourcePhysicalName(logicalId, change.newValue.Properties?.FunctionName, evaluateCfnTemplate);
if (!functionName) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}

const functionArn = await evaluateCfnTemplate.evaluateCfnExpression({
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:' + functionName,
});
const functionName = await establishResourcePhysicalName(logicalId, change.newValue.Properties?.FunctionName, evaluateCfnTemplate);
if (!functionName) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

const functionArn = await evaluateCfnTemplate.evaluateCfnExpression({
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:' + functionName,
});

// find all Lambda Versions that reference this Function
const versionsReferencingFunction = evaluateCfnTemplate.findReferencesTo(logicalId)
.filter(r => r.Type === 'AWS::Lambda::Version');
// find all Lambda Aliases that reference the above Versions
const aliasesReferencingVersions = flatMap(versionsReferencingFunction, v =>
evaluateCfnTemplate.findReferencesTo(v.LogicalId));
const aliasesNames = await Promise.all(aliasesReferencingVersions.map(a =>
evaluateCfnTemplate.evaluateCfnExpression(a.Properties?.Name)));

return new LambdaFunctionHotswapOperation({
physicalName: functionName,
functionArn: functionArn,
resource: lambdaCodeChange,
publishVersion: versionsReferencingFunction.length > 0,
aliasesNames,
});
}

return new LambdaFunctionHotswapOperation({
physicalName: functionName,
functionArn: functionArn,
resource: lambdaCodeChange,
});
/**
* Returns is a given Alias change is only in the 'FunctionVersion' property,
* and `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` is the change is for any other property.
*/
function checkAliasHasVersionOnlyChange(change: HotswappableChangeCandidate): ChangeHotswapResult {
for (const updatedPropName in change.propertyUpdates) {
if (updatedPropName !== 'FunctionVersion') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}
return ChangeHotswapImpact.IRRELEVANT;
}

/**
Expand All @@ -50,7 +88,7 @@ async function isLambdaFunctionCodeOnlyChange(
}

/*
* On first glance, we would want to initialize these using the "previous" values (change.oldValue),
* At 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,
Expand All @@ -60,7 +98,6 @@ async function isLambdaFunctionCodeOnlyChange(
* 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 `''`.
*/
// Make sure only the code in the Lambda function changed
const propertyUpdates = change.propertyUpdates;
let code: LambdaFunctionCode | undefined = undefined;
let tags: LambdaFunctionTags | undefined = undefined;
Expand Down Expand Up @@ -149,14 +186,22 @@ interface LambdaFunctionResource {
readonly physicalName: string;
readonly functionArn: string;
readonly resource: LambdaFunctionChange;
readonly publishVersion: boolean;
readonly aliasesNames: string[];
}

class LambdaFunctionHotswapOperation implements HotswapOperation {
public readonly service = 'lambda-function';
public readonly resourceNames: string[];

constructor(private readonly lambdaFunctionResource: LambdaFunctionResource) {
this.resourceNames = [lambdaFunctionResource.physicalName];
this.resourceNames = [
`Lambda Function '${lambdaFunctionResource.physicalName}'`,
// add Version here if we're publishing a new one
...(lambdaFunctionResource.publishVersion ? [`Lambda Version for Function '${lambdaFunctionResource.physicalName}'`] : []),
// add any Aliases that we are hotswapping here
...lambdaFunctionResource.aliasesNames.map(alias => `Lambda Alias '${alias}' for Function '${lambdaFunctionResource.physicalName}'`),
];
}

public async apply(sdk: ISDK): Promise<any> {
Expand All @@ -165,11 +210,44 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
const operations: Promise<any>[] = [];

if (resource.code !== undefined) {
operations.push(lambda.updateFunctionCode({
const updateFunctionCodePromise = lambda.updateFunctionCode({
FunctionName: this.lambdaFunctionResource.physicalName,
S3Bucket: resource.code.s3Bucket,
S3Key: resource.code.s3Key,
}).promise());
}).promise();

// only if the code changed is there any point in publishing a new Version
if (this.lambdaFunctionResource.publishVersion) {
// we need to wait for the code update to be done before publishing a new Version
await updateFunctionCodePromise;
// if we don't wait for the Function to finish updating,
// we can get a "The operation cannot be performed at this time. An update is in progress for resource:"
// error when publishing a new Version
await lambda.waitFor('functionUpdated', {
FunctionName: this.lambdaFunctionResource.physicalName,
}).promise();

const publishVersionPromise = lambda.publishVersion({
FunctionName: this.lambdaFunctionResource.physicalName,
}).promise();

if (this.lambdaFunctionResource.aliasesNames.length > 0) {
// we need to wait for the Version to finish publishing
const versionUpdate = await publishVersionPromise;

for (const alias of this.lambdaFunctionResource.aliasesNames) {
operations.push(lambda.updateAlias({
FunctionName: this.lambdaFunctionResource.physicalName,
Name: alias,
FunctionVersion: versionUpdate.Version,
}).promise());
}
} else {
operations.push(publishVersionPromise);
}
} else {
operations.push(updateFunctionCodePromise);
}
}

if (resource.tags !== undefined) {
Expand All @@ -184,7 +262,6 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
tagsToSet[tagName] = tagValue as string;
});


if (tagsToDelete.length > 0) {
operations.push(lambda.untagResource({
Resource: this.lambdaFunctionResource.functionArn,
Expand Down
14 changes: 3 additions & 11 deletions packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts
@@ -1,5 +1,5 @@
import { ISDK } from '../aws-auth';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate/*, establishResourcePhysicalName*/ } from './common';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate } from './common';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

/**
Expand Down Expand Up @@ -40,7 +40,7 @@ class S3BucketDeploymentHotswapOperation implements HotswapOperation {
public readonly resourceNames: string[];

constructor(private readonly functionName: string, private readonly customResourceProperties: any) {
this.resourceNames = [this.customResourceProperties.DestinationBucketName];
this.resourceNames = [`Contents of S3 Bucket '${this.customResourceProperties.DestinationBucketName}'`];
}

public async apply(sdk: ISDK): Promise<any> {
Expand Down Expand Up @@ -95,7 +95,7 @@ async function changeIsForS3DeployCustomResourcePolicy(
}
}

return new EmptyHotswapOperation();
return ChangeHotswapImpact.IRRELEVANT;
}

function stringifyObject(obj: any): any {
Expand All @@ -115,11 +115,3 @@ function stringifyObject(obj: any): any {
}
return ret;
}

class EmptyHotswapOperation implements HotswapOperation {
readonly service = 'empty';
public readonly resourceNames = [];
public async apply(sdk: ISDK): Promise<any> {
return Promise.resolve(sdk);
}
}

0 comments on commit 13d77b7

Please sign in to comment.