Skip to content

Commit

Permalink
feat(cli): support hotswapping Lambda function's description and envi…
Browse files Browse the repository at this point in the history
…ronment variables (#21532)

CDK users who deploy their Lambda function with S3 code provider cannot use hotswap deployment today. It's because -- in order for the function version and alias to work -- they need to use a dynamic value in either function's description or environment variable. Since neither of them is supported by hotswap, CDK always performs a full deployment. 

This change allows Lambda function to be hotswappable when there's change in the function's description and/or environment variables. These changes are categorized as configuration changes and are updated by calling `updateFunctionConfiguration`. Since the existing waiter `UpdateFunctionCodeToFinish` is now used to wait for both code update and configuration update, I renamed it to `UpdateFunctionPropertiesToFinish`.

Functional wise, this PR is identical to the now-reverted changes in #21305. The only difference is the fix for integration test (commit [#82dbd4)](82dbd41)

resolves #20787

----


#21305


### All Submissions:

* [ X ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md)

### Adding new Unconventional Dependencies:

* [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies)

### New Features

* [ X ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)?
	* [  ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)?

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
huyphan committed Aug 10, 2022
1 parent 0dd34dd commit b1777d2
Show file tree
Hide file tree
Showing 6 changed files with 293 additions and 24 deletions.
3 changes: 2 additions & 1 deletion packages/aws-cdk/README.md
Expand Up @@ -375,7 +375,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 (including Docker image and inline code) and tag changes of AWS Lambda functions.
- Code asset (including Docker image and inline code), tag changes, and configuration changes (only
description and environment variables are supported) 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.
Expand Down
63 changes: 48 additions & 15 deletions packages/aws-cdk/lib/api/hotswap/lambda-functions.ts
Expand Up @@ -107,6 +107,8 @@ async function isLambdaFunctionCodeOnlyChange(
const propertyUpdates = change.propertyUpdates;
let code: LambdaFunctionCode | undefined = undefined;
let tags: LambdaFunctionTags | undefined = undefined;
let description: string | undefined = undefined;
let environment: { [key: string]: string } | undefined = undefined;

for (const updatedPropName in propertyUpdates) {
const updatedProp = propertyUpdates[updatedPropName];
Expand Down Expand Up @@ -175,12 +177,19 @@ async function isLambdaFunctionCodeOnlyChange(
tags = { tagUpdates };
}
break;
case 'Description':
description = updatedProp.newValue;
break;
case 'Environment':
environment = updatedProp.newValue;
break;
default:
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}

return code || tags ? { code, tags } : ChangeHotswapImpact.IRRELEVANT;
const configurations = description || environment ? { description, environment } : undefined;
return code || tags || configurations ? { code, tags, configurations } : ChangeHotswapImpact.IRRELEVANT;
}

interface CfnDiffTagValue {
Expand All @@ -203,9 +212,15 @@ interface LambdaFunctionTags {
readonly tagUpdates: { [tag : string] : string | TagDeletion };
}

interface LambdaFunctionConfigurations {
readonly description?: string;
readonly environment?: { [key: string]: string };
}

interface LambdaFunctionChange {
readonly code?: LambdaFunctionCode;
readonly tags?: LambdaFunctionTags;
readonly configurations?: LambdaFunctionConfigurations;
}

interface LambdaFunctionResource {
Expand Down Expand Up @@ -235,16 +250,32 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
const resource = this.lambdaFunctionResource.resource;
const operations: Promise<any>[] = [];

if (resource.code !== undefined) {
const updateFunctionCodeResponse = await lambda.updateFunctionCode({
FunctionName: this.lambdaFunctionResource.physicalName,
S3Bucket: resource.code.s3Bucket,
S3Key: resource.code.s3Key,
ImageUri: resource.code.imageUri,
ZipFile: resource.code.functionCodeZip,
}).promise();
if (resource.code !== undefined || resource.configurations !== undefined) {
if (resource.code !== undefined) {
const updateFunctionCodeResponse = await lambda.updateFunctionCode({
FunctionName: this.lambdaFunctionResource.physicalName,
S3Bucket: resource.code.s3Bucket,
S3Key: resource.code.s3Key,
ImageUri: resource.code.imageUri,
ZipFile: resource.code.functionCodeZip,
}).promise();

await this.waitForLambdasPropertiesUpdateToFinish(updateFunctionCodeResponse, lambda);
}

await this.waitForLambdasCodeUpdateToFinish(updateFunctionCodeResponse, lambda);
if (resource.configurations !== undefined) {
const updateRequest: AWS.Lambda.UpdateFunctionConfigurationRequest = {
FunctionName: this.lambdaFunctionResource.physicalName,
};
if (resource.configurations.description !== undefined) {
updateRequest.Description = resource.configurations.description;
}
if (resource.configurations.environment !== undefined) {
updateRequest.Environment = resource.configurations.environment;
}
const updateFunctionCodeResponse = await lambda.updateFunctionConfiguration(updateRequest).promise();
await this.waitForLambdasPropertiesUpdateToFinish(updateFunctionCodeResponse, lambda);
}

// only if the code changed is there any point in publishing a new Version
if (this.lambdaFunctionResource.publishVersion) {
Expand Down Expand Up @@ -308,7 +339,9 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
* or very slowly. For example, Zip based functions _not_ in a VPC can take ~1 second whereas VPC
* or Container functions can take ~25 seconds (and 'idle' VPC functions can take minutes).
*/
private async waitForLambdasCodeUpdateToFinish(currentFunctionConfiguration: AWS.Lambda.FunctionConfiguration, lambda: AWS.Lambda): Promise<void> {
private async waitForLambdasPropertiesUpdateToFinish(
currentFunctionConfiguration: AWS.Lambda.FunctionConfiguration, lambda: AWS.Lambda,
): Promise<void> {
const functionIsInVpcOrUsesDockerForCode = currentFunctionConfiguration.VpcConfig?.VpcId ||
currentFunctionConfiguration.PackageType === 'Image';

Expand All @@ -318,8 +351,8 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
const delaySeconds = functionIsInVpcOrUsesDockerForCode ? 5 : 1;

// configure a custom waiter to wait for the function update to complete
(lambda as any).api.waiters.updateFunctionCodeToFinish = {
name: 'UpdateFunctionCodeToFinish',
(lambda as any).api.waiters.updateFunctionPropertiesToFinish = {
name: 'UpdateFunctionPropertiesToFinish',
operation: 'getFunction',
// equates to 1 minute for zip function not in a VPC and
// 5 minutes for container functions or function in a VPC
Expand All @@ -341,8 +374,8 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
],
};

const updateFunctionCodeWaiter = new (AWS as any).ResourceWaiter(lambda, 'updateFunctionCodeToFinish');
await updateFunctionCodeWaiter.wait({
const updateFunctionPropertiesWaiter = new (AWS as any).ResourceWaiter(lambda, 'updateFunctionPropertiesToFinish');
await updateFunctionPropertiesWaiter.wait({
FunctionName: this.lambdaFunctionResource.physicalName,
}).promise();
}
Expand Down
Expand Up @@ -119,8 +119,8 @@ test('calls the getFunction() API with a delay of 5', async () => {
// THEN
expect(mockMakeRequest).toHaveBeenCalledWith('getFunction', { FunctionName: 'my-function' });
expect(hotswapMockSdkProvider.getLambdaApiWaiters()).toEqual(expect.objectContaining({
updateFunctionCodeToFinish: expect.objectContaining({
name: 'UpdateFunctionCodeToFinish',
updateFunctionPropertiesToFinish: expect.objectContaining({
name: 'UpdateFunctionPropertiesToFinish',
delay: 5,
}),
}));
Expand Down
Expand Up @@ -2,6 +2,9 @@ import { Lambda } from 'aws-sdk';
import * as setup from './hotswap-test-setup';

let mockUpdateLambdaCode: (params: Lambda.Types.UpdateFunctionCodeRequest) => Lambda.Types.FunctionConfiguration;
let mockUpdateLambdaConfiguration: (
params: Lambda.Types.UpdateFunctionConfigurationRequest
) => Lambda.Types.FunctionConfiguration;
let mockTagResource: (params: Lambda.Types.TagResourceRequest) => {};
let mockUntagResource: (params: Lambda.Types.UntagResourceRequest) => {};
let mockMakeRequest: (operation: string, params: any) => AWS.Request<any, AWS.AWSError>;
Expand All @@ -10,6 +13,7 @@ let hotswapMockSdkProvider: setup.HotswapMockSdkProvider;
beforeEach(() => {
hotswapMockSdkProvider = setup.setupHotswapTests();
mockUpdateLambdaCode = jest.fn().mockReturnValue({});
mockUpdateLambdaConfiguration = jest.fn().mockReturnValue({});
mockTagResource = jest.fn();
mockUntagResource = jest.fn();
mockMakeRequest = jest.fn().mockReturnValue({
Expand All @@ -19,6 +23,7 @@ beforeEach(() => {
});
hotswapMockSdkProvider.stubLambda({
updateFunctionCode: mockUpdateLambdaCode,
updateFunctionConfiguration: mockUpdateLambdaConfiguration,
tagResource: mockTagResource,
untagResource: mockUntagResource,
}, {
Expand Down Expand Up @@ -593,8 +598,8 @@ test('calls getFunction() after function code is updated with delay 1', async ()
// THEN
expect(mockMakeRequest).toHaveBeenCalledWith('getFunction', { FunctionName: 'my-function' });
expect(hotswapMockSdkProvider.getLambdaApiWaiters()).toEqual(expect.objectContaining({
updateFunctionCodeToFinish: expect.objectContaining({
name: 'UpdateFunctionCodeToFinish',
updateFunctionPropertiesToFinish: expect.objectContaining({
name: 'UpdateFunctionPropertiesToFinish',
delay: 1,
}),
}));
Expand Down Expand Up @@ -654,8 +659,8 @@ test('calls getFunction() after function code is updated and VpcId is empty stri

// THEN
expect(hotswapMockSdkProvider.getLambdaApiWaiters()).toEqual(expect.objectContaining({
updateFunctionCodeToFinish: expect.objectContaining({
name: 'UpdateFunctionCodeToFinish',
updateFunctionPropertiesToFinish: expect.objectContaining({
name: 'UpdateFunctionPropertiesToFinish',
delay: 1,
}),
}));
Expand Down Expand Up @@ -715,9 +720,188 @@ test('calls getFunction() after function code is updated on a VPC function with

// THEN
expect(hotswapMockSdkProvider.getLambdaApiWaiters()).toEqual(expect.objectContaining({
updateFunctionCodeToFinish: expect.objectContaining({
name: 'UpdateFunctionCodeToFinish',
updateFunctionPropertiesToFinish: expect.objectContaining({
name: 'UpdateFunctionPropertiesToFinish',
delay: 5,
}),
}));
});


test('calls the updateLambdaConfiguration() API when it receives difference in Description field of a Lambda function', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Func: {
Type: 'AWS::Lambda::Function',
Properties: {
Code: {
S3Bucket: 's3-bucket',
S3Key: 's3-key',
},
FunctionName: 'my-function',
Description: 'Old Description',
},
Metadata: {
'aws:asset:path': 'asset-path',
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Func: {
Type: 'AWS::Lambda::Function',
Properties: {
Code: {
S3Bucket: 's3-bucket',
S3Key: 's3-key',
},
FunctionName: 'my-function',
Description: 'New Description',
},
Metadata: {
'aws:asset:path': 'asset-path',
},
},
},
},
});

// WHEN
const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).not.toBeUndefined();
expect(mockUpdateLambdaConfiguration).toHaveBeenCalledWith({
FunctionName: 'my-function',
Description: 'New Description',
});
});

test('calls the updateLambdaConfiguration() API when it receives difference in Environment field of a Lambda function', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Func: {
Type: 'AWS::Lambda::Function',
Properties: {
Code: {
S3Bucket: 's3-bucket',
S3Key: 's3-key',
},
FunctionName: 'my-function',
Environment: {
Variables: {
Key1: 'Value1',
Key2: 'Value2',
},
},
},
Metadata: {
'aws:asset:path': 'asset-path',
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Func: {
Type: 'AWS::Lambda::Function',
Properties: {
Code: {
S3Bucket: 's3-bucket',
S3Key: 's3-key',
},
FunctionName: 'my-function',
Environment: {
Variables: {
Key1: 'Value1',
Key2: 'Value2',
NewKey: 'NewValue',
},
},
},
Metadata: {
'aws:asset:path': 'asset-path',
},
},
},
},
});

// WHEN
const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).not.toBeUndefined();
expect(mockUpdateLambdaConfiguration).toHaveBeenCalledWith({
FunctionName: 'my-function',
Environment: {
Variables: {
Key1: 'Value1',
Key2: 'Value2',
NewKey: 'NewValue',
},
},
});
});

test('calls both updateLambdaCode() and updateLambdaConfiguration() API when it receives both code and configuration change', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Func: {
Type: 'AWS::Lambda::Function',
Properties: {
Code: {
S3Bucket: 'current-bucket',
S3Key: 'current-key',
},
FunctionName: 'my-function',
Description: 'Old Description',
},
Metadata: {
'aws:asset:path': 'asset-path',
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Func: {
Type: 'AWS::Lambda::Function',
Properties: {
Code: {
S3Bucket: 'new-bucket',
S3Key: 'new-key',
},
FunctionName: 'my-function',
Description: 'New Description',
},
Metadata: {
'aws:asset:path': 'asset-path',
},
},
},
},
});

// WHEN
const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).not.toBeUndefined();
expect(mockUpdateLambdaConfiguration).toHaveBeenCalledWith({
FunctionName: 'my-function',
Description: 'New Description',
});
expect(mockUpdateLambdaCode).toHaveBeenCalledWith({
FunctionName: 'my-function',
S3Bucket: 'new-bucket',
S3Key: 'new-key',
});
});

0 comments on commit b1777d2

Please sign in to comment.