Skip to content

Commit 13d77b7

Browse files
authored
feat(cli): support for hotswapping Lambda Versions and Aliases (#18145)
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*
1 parent e2c8063 commit 13d77b7

11 files changed

+407
-61
lines changed

packages/aws-cdk/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,8 @@ and that you have the necessary IAM permissions to update the resources that are
362362
Hotswapping is currently supported for the following changes
363363
(additional changes will be supported in the future):
364364

365-
- Code asset changes of AWS Lambda functions.
365+
- Code asset and tag changes of AWS Lambda functions.
366+
- AWS Lambda Versions and Aliases changes.
366367
- Definition changes of AWS Step Functions State Machines.
367368
- Container asset changes of AWS ECS Services.
368369
- Website asset changes of AWS S3 Bucket Deployments.

packages/aws-cdk/lib/api/hotswap-deployments.ts

+85-14
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,12 @@ export async function tryHotswapDeployment(
6161
async function findAllHotswappableChanges(
6262
stackChanges: cfn_diff.TemplateDiff, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
6363
): Promise<HotswapOperation[] | undefined> {
64+
const resourceDifferences = getStackResourceDifferences(stackChanges);
65+
6466
let foundNonHotswappableChange = false;
6567
const promises: Array<Array<Promise<ChangeHotswapResult>>> = [];
66-
6768
// gather the results of the detector functions
68-
stackChanges.resources.forEachDifference((logicalId: string, change: cfn_diff.ResourceDifference) => {
69+
for (const [logicalId, change] of Object.entries(resourceDifferences)) {
6970
const resourceHotswapEvaluation = isCandidateForHotswapping(change);
7071

7172
if (resourceHotswapEvaluation === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
@@ -81,17 +82,16 @@ async function findAllHotswappableChanges(
8182
isHotswappableCodeBuildProjectChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
8283
]);
8384
}
84-
});
85+
}
8586

87+
// resolve all detector results
8688
const changesDetectionResults: Array<Array<ChangeHotswapResult>> = [];
8789
for (const detectorResultPromises of promises) {
8890
const hotswapDetectionResults = await Promise.all(detectorResultPromises);
8991
changesDetectionResults.push(hotswapDetectionResults);
9092
}
9193

9294
const hotswappableResources = new Array<HotswapOperation>();
93-
94-
// resolve all detector results
9595
for (const hotswapDetectionResults of changesDetectionResults) {
9696
const perChangeHotswappableResources = new Array<HotswapOperation>();
9797

@@ -107,23 +107,94 @@ async function findAllHotswappableChanges(
107107
continue;
108108
}
109109

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

119118
return foundNonHotswappableChange ? undefined : hotswappableResources;
120119
}
121120

121+
/**
122+
* Returns all changes to resources in the given Stack.
123+
*
124+
* @param stackChanges the collection of all changes to a given Stack
125+
*/
126+
function getStackResourceDifferences(stackChanges: cfn_diff.TemplateDiff): { [logicalId: string]: cfn_diff.ResourceDifference } {
127+
// we need to collapse logical ID rename changes into one change,
128+
// as they are represented in stackChanges as a pair of two changes: one addition and one removal
129+
const allResourceChanges: { [logId: string]: cfn_diff.ResourceDifference } = stackChanges.resources.changes;
130+
const allRemovalChanges = filterDict(allResourceChanges, resChange => resChange.isRemoval);
131+
const allNonRemovalChanges = filterDict(allResourceChanges, resChange => !resChange.isRemoval);
132+
for (const [logId, nonRemovalChange] of Object.entries(allNonRemovalChanges)) {
133+
if (nonRemovalChange.isAddition) {
134+
const addChange = nonRemovalChange;
135+
// search for an identical removal change
136+
const identicalRemovalChange = Object.entries(allRemovalChanges).find(([_, remChange]) => {
137+
return changesAreForSameResource(remChange, addChange);
138+
});
139+
// if we found one, then this means this is a rename change
140+
if (identicalRemovalChange) {
141+
const [removedLogId, removedResourceChange] = identicalRemovalChange;
142+
allNonRemovalChanges[logId] = makeRenameDifference(removedResourceChange, addChange);
143+
// delete the removal change that forms the rename pair
144+
delete allRemovalChanges[removedLogId];
145+
}
146+
}
147+
}
148+
// the final result are all of the remaining removal changes,
149+
// plus all of the non-removal changes
150+
// (we saved the rename changes in that object already)
151+
return {
152+
...allRemovalChanges,
153+
...allNonRemovalChanges,
154+
};
155+
}
156+
157+
/** Filters an object with string keys based on whether the callback returns 'true' for the given value in the object. */
158+
function filterDict<T>(dict: { [key: string]: T }, func: (t: T) => boolean): { [key: string]: T } {
159+
return Object.entries(dict).reduce((acc, [key, t]) => {
160+
if (func(t)) {
161+
acc[key] = t;
162+
}
163+
return acc;
164+
}, {} as { [key: string]: T });
165+
}
166+
167+
/** Returns 'true' if a pair of changes is for the same resource. */
168+
function changesAreForSameResource(oldChange: cfn_diff.ResourceDifference, newChange: cfn_diff.ResourceDifference): boolean {
169+
return oldChange.oldResourceType === newChange.newResourceType &&
170+
// this isn't great, but I don't want to bring in something like underscore just for this comparison
171+
JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties);
172+
}
173+
174+
function makeRenameDifference(
175+
remChange: cfn_diff.ResourceDifference,
176+
addChange: cfn_diff.ResourceDifference,
177+
): cfn_diff.ResourceDifference {
178+
return new cfn_diff.ResourceDifference(
179+
// we have to fill in the old value, because otherwise this will be classified as a non-hotswappable change
180+
remChange.oldValue,
181+
addChange.newValue,
182+
{
183+
resourceType: {
184+
oldType: remChange.oldResourceType,
185+
newType: addChange.newResourceType,
186+
},
187+
propertyDiffs: (addChange as any).propertyDiffs,
188+
otherDiffs: (addChange as any).otherDiffs,
189+
},
190+
);
191+
}
192+
122193
/**
123194
* returns `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if a resource was deleted, or a change that we cannot short-circuit occured.
124195
* Returns `ChangeHotswapImpact.IRRELEVANT` if a change that does not impact shortcircuiting occured, such as a metadata change.
125196
*/
126-
export function isCandidateForHotswapping(change: cfn_diff.ResourceDifference): HotswappableChangeCandidate | ChangeHotswapImpact {
197+
function isCandidateForHotswapping(change: cfn_diff.ResourceDifference): HotswappableChangeCandidate | ChangeHotswapImpact {
127198
// a resource has been removed OR a resource has been added; we can't short-circuit that change
128199
if (!change.newValue || !change.oldValue) {
129200
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
@@ -156,12 +227,12 @@ async function applyHotswappableChange(sdk: ISDK, hotswapOperation: HotswapOpera
156227

157228
try {
158229
for (const name of hotswapOperation.resourceNames) {
159-
print(` ${ICON} hotswapping ${hotswapOperation.service}: %s`, colors.bold(name));
230+
print(` ${ICON} %s`, colors.bold(name));
160231
}
161232
return await hotswapOperation.apply(sdk);
162233
} finally {
163234
for (const name of hotswapOperation.resourceNames) {
164-
print(`${ICON} ${hotswapOperation.service}: %s %s`, colors.bold(name), colors.green('hotswapped!'));
235+
print(`${ICON} %s %s`, colors.bold(name), colors.green('hotswapped!'));
165236
}
166237
sdk.removeCustomUserAgent(customUserAgent);
167238
}

packages/aws-cdk/lib/api/hotswap/code-build-projects.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class ProjectHotswapOperation implements HotswapOperation {
5151
constructor(
5252
private readonly updateProjectInput: AWS.CodeBuild.UpdateProjectInput,
5353
) {
54-
this.resourceNames = [updateProjectInput.name];
54+
this.resourceNames = [`CodeBuild project '${updateProjectInput.name}'`];
5555
}
5656

5757
public async apply(sdk: ISDK): Promise<any> {

packages/aws-cdk/lib/api/hotswap/ecs-services.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ class EcsServiceHotswapOperation implements HotswapOperation {
8383
private readonly taskDefinitionResource: any,
8484
private readonly servicesReferencingTaskDef: EcsService[],
8585
) {
86-
this.resourceNames = servicesReferencingTaskDef.map(ecsService => ecsService.serviceArn.split('/')[2]);
86+
this.resourceNames = servicesReferencingTaskDef.map(ecsService =>
87+
`ECS Service '${ecsService.serviceArn.split('/')[2]}'`);
8788
}
8889

8990
public async apply(sdk: ISDK): Promise<any> {

packages/aws-cdk/lib/api/hotswap/lambda-functions.ts

+97-20
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { flatMap } from '../../util';
12
import { ISDK } from '../aws-auth';
2-
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, establishResourcePhysicalName } from './common';
3+
import { ChangeHotswapImpact, ChangeHotswapResult, establishResourcePhysicalName, HotswapOperation, HotswappableChangeCandidate } from './common';
34
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
45

56
/**
@@ -11,25 +12,62 @@ import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-templa
1112
export async function isHotswappableLambdaFunctionChange(
1213
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
1314
): Promise<ChangeHotswapResult> {
15+
// if the change is for a Lambda Version,
16+
// ignore it by returning an empty hotswap operation -
17+
// we will publish a new version when we get to hotswapping the actual Function this Version points to, below
18+
// (Versions can't be changed in CloudFormation anyway, they're immutable)
19+
if (change.newValue.Type === 'AWS::Lambda::Version') {
20+
return ChangeHotswapImpact.IRRELEVANT;
21+
}
22+
23+
// we handle Aliases specially too
24+
if (change.newValue.Type === 'AWS::Lambda::Alias') {
25+
return checkAliasHasVersionOnlyChange(change);
26+
}
27+
1428
const lambdaCodeChange = await isLambdaFunctionCodeOnlyChange(change, evaluateCfnTemplate);
1529
if (typeof lambdaCodeChange === 'string') {
1630
return lambdaCodeChange;
17-
} else {
18-
const functionName = await establishResourcePhysicalName(logicalId, change.newValue.Properties?.FunctionName, evaluateCfnTemplate);
19-
if (!functionName) {
20-
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
21-
}
31+
}
2232

23-
const functionArn = await evaluateCfnTemplate.evaluateCfnExpression({
24-
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:' + functionName,
25-
});
33+
const functionName = await establishResourcePhysicalName(logicalId, change.newValue.Properties?.FunctionName, evaluateCfnTemplate);
34+
if (!functionName) {
35+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
36+
}
37+
38+
const functionArn = await evaluateCfnTemplate.evaluateCfnExpression({
39+
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:' + functionName,
40+
});
41+
42+
// find all Lambda Versions that reference this Function
43+
const versionsReferencingFunction = evaluateCfnTemplate.findReferencesTo(logicalId)
44+
.filter(r => r.Type === 'AWS::Lambda::Version');
45+
// find all Lambda Aliases that reference the above Versions
46+
const aliasesReferencingVersions = flatMap(versionsReferencingFunction, v =>
47+
evaluateCfnTemplate.findReferencesTo(v.LogicalId));
48+
const aliasesNames = await Promise.all(aliasesReferencingVersions.map(a =>
49+
evaluateCfnTemplate.evaluateCfnExpression(a.Properties?.Name)));
50+
51+
return new LambdaFunctionHotswapOperation({
52+
physicalName: functionName,
53+
functionArn: functionArn,
54+
resource: lambdaCodeChange,
55+
publishVersion: versionsReferencingFunction.length > 0,
56+
aliasesNames,
57+
});
58+
}
2659

27-
return new LambdaFunctionHotswapOperation({
28-
physicalName: functionName,
29-
functionArn: functionArn,
30-
resource: lambdaCodeChange,
31-
});
60+
/**
61+
* Returns is a given Alias change is only in the 'FunctionVersion' property,
62+
* and `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` is the change is for any other property.
63+
*/
64+
function checkAliasHasVersionOnlyChange(change: HotswappableChangeCandidate): ChangeHotswapResult {
65+
for (const updatedPropName in change.propertyUpdates) {
66+
if (updatedPropName !== 'FunctionVersion') {
67+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
68+
}
3269
}
70+
return ChangeHotswapImpact.IRRELEVANT;
3371
}
3472

3573
/**
@@ -50,7 +88,7 @@ async function isLambdaFunctionCodeOnlyChange(
5088
}
5189

5290
/*
53-
* On first glance, we would want to initialize these using the "previous" values (change.oldValue),
91+
* At first glance, we would want to initialize these using the "previous" values (change.oldValue),
5492
* in case only one of them changed, like the key, and the Bucket stayed the same.
5593
* However, that actually fails for old-style synthesis, which uses CFN Parameters!
5694
* Because the names of the Parameters depend on the hash of the Asset,
@@ -60,7 +98,6 @@ async function isLambdaFunctionCodeOnlyChange(
6098
* even if only one of them was actually changed,
6199
* which means we don't need the "old" values at all, and we can safely initialize these with just `''`.
62100
*/
63-
// Make sure only the code in the Lambda function changed
64101
const propertyUpdates = change.propertyUpdates;
65102
let code: LambdaFunctionCode | undefined = undefined;
66103
let tags: LambdaFunctionTags | undefined = undefined;
@@ -149,14 +186,22 @@ interface LambdaFunctionResource {
149186
readonly physicalName: string;
150187
readonly functionArn: string;
151188
readonly resource: LambdaFunctionChange;
189+
readonly publishVersion: boolean;
190+
readonly aliasesNames: string[];
152191
}
153192

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

158197
constructor(private readonly lambdaFunctionResource: LambdaFunctionResource) {
159-
this.resourceNames = [lambdaFunctionResource.physicalName];
198+
this.resourceNames = [
199+
`Lambda Function '${lambdaFunctionResource.physicalName}'`,
200+
// add Version here if we're publishing a new one
201+
...(lambdaFunctionResource.publishVersion ? [`Lambda Version for Function '${lambdaFunctionResource.physicalName}'`] : []),
202+
// add any Aliases that we are hotswapping here
203+
...lambdaFunctionResource.aliasesNames.map(alias => `Lambda Alias '${alias}' for Function '${lambdaFunctionResource.physicalName}'`),
204+
];
160205
}
161206

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

167212
if (resource.code !== undefined) {
168-
operations.push(lambda.updateFunctionCode({
213+
const updateFunctionCodePromise = lambda.updateFunctionCode({
169214
FunctionName: this.lambdaFunctionResource.physicalName,
170215
S3Bucket: resource.code.s3Bucket,
171216
S3Key: resource.code.s3Key,
172-
}).promise());
217+
}).promise();
218+
219+
// only if the code changed is there any point in publishing a new Version
220+
if (this.lambdaFunctionResource.publishVersion) {
221+
// we need to wait for the code update to be done before publishing a new Version
222+
await updateFunctionCodePromise;
223+
// if we don't wait for the Function to finish updating,
224+
// we can get a "The operation cannot be performed at this time. An update is in progress for resource:"
225+
// error when publishing a new Version
226+
await lambda.waitFor('functionUpdated', {
227+
FunctionName: this.lambdaFunctionResource.physicalName,
228+
}).promise();
229+
230+
const publishVersionPromise = lambda.publishVersion({
231+
FunctionName: this.lambdaFunctionResource.physicalName,
232+
}).promise();
233+
234+
if (this.lambdaFunctionResource.aliasesNames.length > 0) {
235+
// we need to wait for the Version to finish publishing
236+
const versionUpdate = await publishVersionPromise;
237+
238+
for (const alias of this.lambdaFunctionResource.aliasesNames) {
239+
operations.push(lambda.updateAlias({
240+
FunctionName: this.lambdaFunctionResource.physicalName,
241+
Name: alias,
242+
FunctionVersion: versionUpdate.Version,
243+
}).promise());
244+
}
245+
} else {
246+
operations.push(publishVersionPromise);
247+
}
248+
} else {
249+
operations.push(updateFunctionCodePromise);
250+
}
173251
}
174252

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

187-
188265
if (tagsToDelete.length > 0) {
189266
operations.push(lambda.untagResource({
190267
Resource: this.lambdaFunctionResource.functionArn,

packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts

+3-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ISDK } from '../aws-auth';
2-
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate/*, establishResourcePhysicalName*/ } from './common';
2+
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate } from './common';
33
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
44

55
/**
@@ -40,7 +40,7 @@ class S3BucketDeploymentHotswapOperation implements HotswapOperation {
4040
public readonly resourceNames: string[];
4141

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

4646
public async apply(sdk: ISDK): Promise<any> {
@@ -95,7 +95,7 @@ async function changeIsForS3DeployCustomResourcePolicy(
9595
}
9696
}
9797

98-
return new EmptyHotswapOperation();
98+
return ChangeHotswapImpact.IRRELEVANT;
9999
}
100100

101101
function stringifyObject(obj: any): any {
@@ -115,11 +115,3 @@ function stringifyObject(obj: any): any {
115115
}
116116
return ret;
117117
}
118-
119-
class EmptyHotswapOperation implements HotswapOperation {
120-
readonly service = 'empty';
121-
public readonly resourceNames = [];
122-
public async apply(sdk: ISDK): Promise<any> {
123-
return Promise.resolve(sdk);
124-
}
125-
}

0 commit comments

Comments
 (0)