From 8afab4576029e08408a7193b231176a75adb5dd9 Mon Sep 17 00:00:00 2001 From: Huy Phan Date: Mon, 25 Jul 2022 12:00:58 +1000 Subject: [PATCH 1/3] feat(cli): support hotswapping Lambda function's description and environment variables This change allows Lambda function to be hotswap'ed 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". --- packages/aws-cdk/README.md | 3 +- .../lib/api/hotswap/lambda-functions.ts | 63 ++++-- ...nctions-docker-hotswap-deployments.test.ts | 4 +- ...mbda-functions-hotswap-deployments.test.ts | 196 +++++++++++++++++- 4 files changed, 242 insertions(+), 24 deletions(-) diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 4fe06d1878abf..a9a35f644d2fa 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -373,7 +373,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. diff --git a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts index adcc8b5c1aea8..43503c84c2b43 100644 --- a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts +++ b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts @@ -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]; @@ -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 { @@ -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 { @@ -235,16 +250,32 @@ class LambdaFunctionHotswapOperation implements HotswapOperation { const resource = this.lambdaFunctionResource.resource; const operations: Promise[] = []; - 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) { @@ -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 { + private async waitForLambdasPropertiesUpdateToFinish( + currentFunctionConfiguration: AWS.Lambda.FunctionConfiguration, lambda: AWS.Lambda, + ): Promise { const functionIsInVpcOrUsesDockerForCode = currentFunctionConfiguration.VpcConfig?.VpcId || currentFunctionConfiguration.PackageType === 'Image'; @@ -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 @@ -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(); } diff --git a/packages/aws-cdk/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts index 3ed53e5fd908b..9685b3ef1c0b4 100644 --- a/packages/aws-cdk/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts +++ b/packages/aws-cdk/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts @@ -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, }), })); diff --git a/packages/aws-cdk/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts index d96b8530bce88..8a1d356a1e9ca 100644 --- a/packages/aws-cdk/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts +++ b/packages/aws-cdk/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts @@ -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; @@ -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({ @@ -19,6 +23,7 @@ beforeEach(() => { }); hotswapMockSdkProvider.stubLambda({ updateFunctionCode: mockUpdateLambdaCode, + updateFunctionConfiguration: mockUpdateLambdaConfiguration, tagResource: mockTagResource, untagResource: mockUntagResource, }, { @@ -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, }), })); @@ -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, }), })); @@ -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', + }); +}); From 5d640ae7375cfc0db13f5ab518891d78645c0fd0 Mon Sep 17 00:00:00 2001 From: Huy Phan Date: Mon, 1 Aug 2022 09:06:45 +1000 Subject: [PATCH 2/3] chore(cli): add integration test for Lambda function's hotswap feature This commit adds a basic integration test for the hotswap feature, for the case when the Lambda function's description and environment variables have changed. --- packages/aws-cdk/test/integ/cli/app/app.js | 19 ++++++++++++++ .../aws-cdk/test/integ/cli/cli.integtest.ts | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/aws-cdk/test/integ/cli/app/app.js b/packages/aws-cdk/test/integ/cli/app/app.js index 5db2d80fe42df..9f5fe09ca68c7 100755 --- a/packages/aws-cdk/test/integ/cli/app/app.js +++ b/packages/aws-cdk/test/integ/cli/app/app.js @@ -228,6 +228,24 @@ class LambdaStack extends cdk.Stack { } } +class LambdaHotswapStack extends cdk.Stack { + constructor(parent, id, props) { + super(parent, id, props); + + const fn = new lambda.Function(this, 'my-function', { + code: lambda.Code.asset(path.join(__dirname, 'lambda')), + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + description: new Date().toISOString(), + environment: { + SomeVariable: new Date().toISOString(), + } + }); + + new cdk.CfnOutput(this, 'FunctionName', { value: fn.functionName }); + } +} + class DockerStack extends cdk.Stack { constructor(parent, id, props) { super(parent, id, props); @@ -379,6 +397,7 @@ switch (stackSet) { new MissingSSMParameterStack(app, `${stackPrefix}-missing-ssm-parameter`, { env: defaultEnv }); new LambdaStack(app, `${stackPrefix}-lambda`); + new LambdaHotswapStack(app, `${stackPrefix}-lambda-hotswap`); new DockerStack(app, `${stackPrefix}-docker`); new DockerStackWithCustomFile(app, `${stackPrefix}-docker-with-custom-file`); new FailedStack(app, `${stackPrefix}-failed`) diff --git a/packages/aws-cdk/test/integ/cli/cli.integtest.ts b/packages/aws-cdk/test/integ/cli/cli.integtest.ts index b3ff437771601..c937c4e83d83c 100644 --- a/packages/aws-cdk/test/integ/cli/cli.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/cli.integtest.ts @@ -1096,6 +1096,32 @@ integTest('test resource import', withDefaultFixture(async (fixture) => { } })); +integTest('hotswap deployment supports Lambda function\'s description and environment variables', withDefaultFixture(async (fixture) => { + // GIVEN + const stackArn = await fixture.cdkDeploy('lambda-hotswap', { + captureStderr: false, + }); + + // WHEN + const deployOutput = await fixture.cdkDeploy('lambda-hotswap', { + options: ['--hotswap'], + captureStderr: true, + onlyStderr: true, + }); + + const response = await fixture.aws.cloudFormation('describeStacks', { + StackName: stackArn, + }); + const functionName = response.Stacks?.[0].Outputs?.[0].OutputValue; + + // THEN + + // The deployment should not trigger a full deployment, thus the stack's status must remains + // "CREATE_COMPLETE" + expect(response.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE'); + expect(deployOutput).toContain(`Lambda Function '${functionName}' hotswapped!`); +})); + async function listChildren(parent: string, pred: (x: string) => Promise) { const ret = new Array(); for (const child of await fs.readdir(parent, { encoding: 'utf-8' })) { From 82dbd4194eba9dc633f0fcbf57c067cc312401a9 Mon Sep 17 00:00:00 2001 From: Huy Phan Date: Wed, 10 Aug 2022 14:09:42 +1000 Subject: [PATCH 3/3] Only mutate the lambda function's properties in integration test when the test needs to --- packages/aws-cdk/test/integ/cli/app/app.js | 4 ++-- packages/aws-cdk/test/integ/cli/cli.integtest.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/aws-cdk/test/integ/cli/app/app.js b/packages/aws-cdk/test/integ/cli/app/app.js index 9f5fe09ca68c7..8c6a722bdb674 100755 --- a/packages/aws-cdk/test/integ/cli/app/app.js +++ b/packages/aws-cdk/test/integ/cli/app/app.js @@ -236,9 +236,9 @@ class LambdaHotswapStack extends cdk.Stack { code: lambda.Code.asset(path.join(__dirname, 'lambda')), runtime: lambda.Runtime.NODEJS_14_X, handler: 'index.handler', - description: new Date().toISOString(), + description: process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "description", environment: { - SomeVariable: new Date().toISOString(), + SomeVariable: process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "environment", } }); diff --git a/packages/aws-cdk/test/integ/cli/cli.integtest.ts b/packages/aws-cdk/test/integ/cli/cli.integtest.ts index c937c4e83d83c..ba3519481e883 100644 --- a/packages/aws-cdk/test/integ/cli/cli.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/cli.integtest.ts @@ -1100,6 +1100,9 @@ integTest('hotswap deployment supports Lambda function\'s description and enviro // GIVEN const stackArn = await fixture.cdkDeploy('lambda-hotswap', { captureStderr: false, + modEnv: { + DYNAMIC_LAMBDA_PROPERTY_VALUE: 'original value', + }, }); // WHEN @@ -1107,6 +1110,9 @@ integTest('hotswap deployment supports Lambda function\'s description and enviro options: ['--hotswap'], captureStderr: true, onlyStderr: true, + modEnv: { + DYNAMIC_LAMBDA_PROPERTY_VALUE: 'new value', + }, }); const response = await fixture.aws.cloudFormation('describeStacks', {