Skip to content

Commit e4485f4

Browse files
authored
feat(cli): support hotswapping Lambda function tags (#17818)
Fixes #17664 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent cb1f2d4 commit e4485f4

File tree

5 files changed

+220
-46
lines changed

5 files changed

+220
-46
lines changed

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

-4
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,3 @@ export async function establishResourcePhysicalName(
7676
}
7777
return evaluateCfnTemplate.findPhysicalNameFor(logicalId);
7878
}
79-
80-
export function assetMetadataChanged(change: HotswappableChangeCandidate): boolean {
81-
return !!change.newValue?.Metadata['aws:asset:path'];
82-
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ISDK } from '../aws-auth';
2-
import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, establishResourcePhysicalName } from './common';
2+
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, establishResourcePhysicalName } from './common';
33
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
44

55
/**
@@ -15,22 +15,19 @@ export async function isHotswappableLambdaFunctionChange(
1515
if (typeof lambdaCodeChange === 'string') {
1616
return lambdaCodeChange;
1717
} else {
18-
// verify that the Asset changed - otherwise,
19-
// it's a Code property-only change,
20-
// but not to an asset change
21-
// (for example, going from Code.fromAsset() to Code.fromInline())
22-
if (!assetMetadataChanged(change)) {
23-
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
24-
}
25-
2618
const functionName = await establishResourcePhysicalName(logicalId, change.newValue.Properties?.FunctionName, evaluateCfnTemplate);
2719
if (!functionName) {
2820
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
2921
}
3022

23+
const functionArn = await evaluateCfnTemplate.evaluateCfnExpression({
24+
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:' + functionName,
25+
});
26+
3127
return new LambdaFunctionHotswapOperation({
3228
physicalName: functionName,
33-
code: lambdaCodeChange,
29+
functionArn: functionArn,
30+
resource: lambdaCodeChange,
3431
});
3532
}
3633
}
@@ -46,7 +43,7 @@ export async function isHotswappableLambdaFunctionChange(
4643
*/
4744
async function isLambdaFunctionCodeOnlyChange(
4845
change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
49-
): Promise<LambdaFunctionCode | ChangeHotswapImpact> {
46+
): Promise<LambdaFunctionChange | ChangeHotswapImpact> {
5047
const newResourceType = change.newValue.Type;
5148
if (newResourceType !== 'AWS::Lambda::Function') {
5249
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
@@ -63,44 +60,95 @@ async function isLambdaFunctionCodeOnlyChange(
6360
* even if only one of them was actually changed,
6461
* which means we don't need the "old" values at all, and we can safely initialize these with just `''`.
6562
*/
66-
let s3Bucket = '', s3Key = '';
67-
let foundCodeDifference = false;
6863
// Make sure only the code in the Lambda function changed
6964
const propertyUpdates = change.propertyUpdates;
65+
let code: LambdaFunctionCode | undefined = undefined;
66+
let tags: LambdaFunctionTags | undefined = undefined;
67+
7068
for (const updatedPropName in propertyUpdates) {
7169
const updatedProp = propertyUpdates[updatedPropName];
72-
for (const newPropName in updatedProp.newValue) {
73-
switch (newPropName) {
74-
case 'S3Bucket':
75-
foundCodeDifference = true;
76-
s3Bucket = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
77-
break;
78-
case 'S3Key':
79-
foundCodeDifference = true;
80-
s3Key = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
81-
break;
82-
default:
83-
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
84-
}
70+
71+
switch (updatedPropName) {
72+
case 'Code':
73+
let foundCodeDifference = false;
74+
let s3Bucket = '', s3Key = '';
75+
76+
for (const newPropName in updatedProp.newValue) {
77+
switch (newPropName) {
78+
case 'S3Bucket':
79+
foundCodeDifference = true;
80+
s3Bucket = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
81+
break;
82+
case 'S3Key':
83+
foundCodeDifference = true;
84+
s3Key = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
85+
break;
86+
default:
87+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
88+
}
89+
}
90+
if (foundCodeDifference) {
91+
code = {
92+
s3Bucket,
93+
s3Key,
94+
};
95+
}
96+
break;
97+
case 'Tags':
98+
/*
99+
* Tag updates are a bit odd; they manifest as two lists, are flagged only as
100+
* `isDifferent`, and we have to reconcile them.
101+
*/
102+
const tagUpdates: { [tag: string]: string | TagDeletion } = {};
103+
if (updatedProp?.isDifferent) {
104+
updatedProp.newValue.forEach((tag: CfnDiffTagValue) => {
105+
tagUpdates[tag.Key] = tag.Value;
106+
});
107+
108+
updatedProp.oldValue.forEach((tag: CfnDiffTagValue) => {
109+
if (tagUpdates[tag.Key] === undefined) {
110+
tagUpdates[tag.Key] = TagDeletion.DELETE;
111+
}
112+
});
113+
114+
tags = { tagUpdates };
115+
}
116+
break;
117+
default:
118+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
85119
}
86120
}
87121

88-
return foundCodeDifference
89-
? {
90-
s3Bucket,
91-
s3Key,
92-
}
93-
: ChangeHotswapImpact.IRRELEVANT;
122+
return code || tags ? { code, tags } : ChangeHotswapImpact.IRRELEVANT;
123+
}
124+
125+
interface CfnDiffTagValue {
126+
readonly Key: string;
127+
readonly Value: string;
94128
}
95129

96130
interface LambdaFunctionCode {
97131
readonly s3Bucket: string;
98132
readonly s3Key: string;
99133
}
100134

135+
enum TagDeletion {
136+
DELETE = -1,
137+
}
138+
139+
interface LambdaFunctionTags {
140+
readonly tagUpdates: { [tag : string] : string | TagDeletion };
141+
}
142+
143+
interface LambdaFunctionChange {
144+
readonly code?: LambdaFunctionCode;
145+
readonly tags?: LambdaFunctionTags;
146+
}
147+
101148
interface LambdaFunctionResource {
102149
readonly physicalName: string;
103-
readonly code: LambdaFunctionCode;
150+
readonly functionArn: string;
151+
readonly resource: LambdaFunctionChange;
104152
}
105153

106154
class LambdaFunctionHotswapOperation implements HotswapOperation {
@@ -110,10 +158,47 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
110158
}
111159

112160
public async apply(sdk: ISDK): Promise<any> {
113-
return sdk.lambda().updateFunctionCode({
114-
FunctionName: this.lambdaFunctionResource.physicalName,
115-
S3Bucket: this.lambdaFunctionResource.code.s3Bucket,
116-
S3Key: this.lambdaFunctionResource.code.s3Key,
117-
}).promise();
161+
const lambda = sdk.lambda();
162+
const resource = this.lambdaFunctionResource.resource;
163+
const operations: Promise<any>[] = [];
164+
165+
if (resource.code !== undefined) {
166+
operations.push(lambda.updateFunctionCode({
167+
FunctionName: this.lambdaFunctionResource.physicalName,
168+
S3Bucket: resource.code.s3Bucket,
169+
S3Key: resource.code.s3Key,
170+
}).promise());
171+
}
172+
173+
if (resource.tags !== undefined) {
174+
const tagsToDelete: string[] = Object.entries(resource.tags.tagUpdates)
175+
.filter(([_key, val]) => val === TagDeletion.DELETE)
176+
.map(([key, _val]) => key);
177+
178+
const tagsToSet: { [tag: string]: string } = {};
179+
Object.entries(resource.tags!.tagUpdates)
180+
.filter(([_key, val]) => val !== TagDeletion.DELETE)
181+
.forEach(([tagName, tagValue]) => {
182+
tagsToSet[tagName] = tagValue as string;
183+
});
184+
185+
186+
if (tagsToDelete.length > 0) {
187+
operations.push(lambda.untagResource({
188+
Resource: this.lambdaFunctionResource.functionArn,
189+
TagKeys: tagsToDelete,
190+
}).promise());
191+
}
192+
193+
if (Object.keys(tagsToSet).length > 0) {
194+
operations.push(lambda.tagResource({
195+
Resource: this.lambdaFunctionResource.functionArn,
196+
Tags: tagsToSet,
197+
}).promise());
198+
}
199+
}
200+
201+
// run all of our updates in parallel
202+
return Promise.all(operations);
118203
}
119204
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ beforeEach(() => {
1111
mockUpdateLambdaCode = jest.fn();
1212
mockUpdateMachineDefinition = jest.fn();
1313
mockGetEndpointSuffix = jest.fn(() => 'amazonaws.com');
14-
hotswapMockSdkProvider.setUpdateFunctionCodeMock(mockUpdateLambdaCode);
14+
hotswapMockSdkProvider.stubLambda(mockUpdateLambdaCode);
1515
hotswapMockSdkProvider.setUpdateStateMachineMock(mockUpdateMachineDefinition);
1616
hotswapMockSdkProvider.stubGetEndpointSuffix(mockGetEndpointSuffix);
1717
});

packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,15 @@ export class HotswapMockSdkProvider {
8282
});
8383
}
8484

85-
public setUpdateFunctionCodeMock(mockUpdateLambdaCode: (input: lambda.UpdateFunctionCodeRequest) => lambda.FunctionConfiguration) {
85+
public stubLambda(
86+
mockUpdateLambdaCode: (input: lambda.UpdateFunctionCodeRequest) => lambda.FunctionConfiguration,
87+
mockTagResource?: (input: lambda.TagResourceRequest) => {},
88+
mockUntagResource?: (input: lambda.UntagResourceRequest) => {},
89+
) {
8690
this.mockSdkProvider.stubLambda({
87-
updateFunctionCode: mockUpdateLambdaCode,
91+
updateFunctionCode: mockUpdateLambdaCode ?? jest.fn(),
92+
tagResource: mockTagResource ?? jest.fn(),
93+
untagResource: mockUntagResource ?? jest.fn(),
8894
});
8995
}
9096

packages/aws-cdk/test/api/hotswap/lambda-hotswap-deployments.test.ts

+88-1
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import { Lambda } from 'aws-sdk';
22
import * as setup from './hotswap-test-setup';
33

44
let mockUpdateLambdaCode: (params: Lambda.Types.UpdateFunctionCodeRequest) => Lambda.Types.FunctionConfiguration;
5+
let mockTagResource: (params: Lambda.Types.TagResourceRequest) => {};
6+
let mockUntagResource: (params: Lambda.Types.UntagResourceRequest) => {};
57
let hotswapMockSdkProvider: setup.HotswapMockSdkProvider;
68

79
beforeEach(() => {
810
hotswapMockSdkProvider = setup.setupHotswapTests();
911
mockUpdateLambdaCode = jest.fn();
10-
hotswapMockSdkProvider.setUpdateFunctionCodeMock(mockUpdateLambdaCode);
12+
mockTagResource = jest.fn();
13+
mockUntagResource = jest.fn();
14+
hotswapMockSdkProvider.stubLambda(mockUpdateLambdaCode, mockTagResource, mockUntagResource);
1115
});
1216

1317
test('returns undefined when a new Lambda function is added to the Stack', async () => {
@@ -80,6 +84,89 @@ test('calls the updateLambdaCode() API when it receives only a code difference i
8084
});
8185
});
8286

87+
test('calls the tagResource() API when it receives only a tag difference in a Lambda function', async () => {
88+
// GIVEN
89+
const currentTemplate = {
90+
Resources: {
91+
Func: {
92+
Type: 'AWS::Lambda::Function',
93+
Properties: {
94+
Code: {
95+
S3Bucket: 'current-bucket',
96+
S3Key: 'current-key',
97+
},
98+
FunctionName: 'my-function',
99+
Tags: [
100+
{
101+
Key: 'to-be-deleted',
102+
Value: 'a-value',
103+
},
104+
{
105+
Key: 'to-be-changed',
106+
Value: 'current-tag-value',
107+
},
108+
],
109+
},
110+
Metadata: {
111+
'aws:asset:path': 'old-path',
112+
},
113+
},
114+
},
115+
};
116+
117+
setup.setCurrentCfnStackTemplate(currentTemplate);
118+
const cdkStackArtifact = setup.cdkStackArtifactOf({
119+
template: {
120+
Resources: {
121+
Func: {
122+
Type: 'AWS::Lambda::Function',
123+
Properties: {
124+
Code: {
125+
S3Bucket: 'current-bucket',
126+
S3Key: 'current-key',
127+
},
128+
FunctionName: 'my-function',
129+
Tags: [
130+
{
131+
Key: 'to-be-changed',
132+
Value: 'new-tag-value',
133+
},
134+
{
135+
Key: 'to-be-added',
136+
Value: 'added-tag-value',
137+
},
138+
],
139+
},
140+
Metadata: {
141+
'aws:asset:path': 'old-path',
142+
},
143+
},
144+
},
145+
},
146+
});
147+
148+
// WHEN
149+
const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact);
150+
151+
// THEN
152+
expect(deployStackResult).not.toBeUndefined();
153+
154+
expect(mockUntagResource).toHaveBeenCalledWith({
155+
Resource: 'arn:aws:lambda:here:123456789012:function:my-function',
156+
TagKeys: ['to-be-deleted'],
157+
});
158+
159+
expect(mockTagResource).toHaveBeenCalledWith({
160+
Resource: 'arn:aws:lambda:here:123456789012:function:my-function',
161+
Tags: {
162+
'to-be-changed': 'new-tag-value',
163+
'to-be-added': 'added-tag-value',
164+
},
165+
});
166+
167+
expect(mockUpdateLambdaCode).not.toHaveBeenCalled();
168+
});
169+
83170
test("correctly evaluates the function's name when it references a different resource from the template", async () => {
84171
// GIVEN
85172
setup.setCurrentCfnStackTemplate({

0 commit comments

Comments
 (0)