diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 3d1e1b237921a..f1c63975641ac 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -362,7 +362,7 @@ 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 tag changes of AWS Lambda functions. +- Code asset (including Docker image and inline code) 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. diff --git a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts index 14d96e85ae6d9..d2966e756b69d 100644 --- a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts +++ b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts @@ -1,6 +1,8 @@ +import { Writable } from 'stream'; +import * as archiver from 'archiver'; import { flatMap } from '../../util'; import { ISDK } from '../aws-auth'; -import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; +import { CfnEvaluationException, EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate } from './common'; /** @@ -108,7 +110,7 @@ async function isLambdaFunctionCodeOnlyChange( switch (updatedPropName) { case 'Code': let foundCodeDifference = false; - let s3Bucket, s3Key, imageUri; + let s3Bucket, s3Key, imageUri, functionCodeZip; for (const newPropName in updatedProp.newValue) { switch (newPropName) { @@ -124,6 +126,18 @@ async function isLambdaFunctionCodeOnlyChange( foundCodeDifference = true; imageUri = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]); break; + case 'ZipFile': + foundCodeDifference = true; + // We must create a zip package containing a file with the inline code + const functionCode = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]); + const functionRuntime = await evaluateCfnTemplate.evaluateCfnExpression(change.newValue.Properties?.Runtime); + if (!functionRuntime) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + // file extension must be chosen depending on the runtime + const codeFileExt = determineCodeFileExtFromRuntime(functionRuntime); + functionCodeZip = await zipString(`index.${codeFileExt}`, functionCode); + break; default: return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; } @@ -133,6 +147,7 @@ async function isLambdaFunctionCodeOnlyChange( s3Bucket, s3Key, imageUri, + functionCodeZip, }; } break; @@ -173,6 +188,7 @@ interface LambdaFunctionCode { readonly s3Bucket?: string; readonly s3Key?: string; readonly imageUri?: string; + readonly functionCodeZip?: Buffer; } enum TagDeletion { @@ -221,6 +237,7 @@ class LambdaFunctionHotswapOperation implements HotswapOperation { S3Bucket: resource.code.s3Bucket, S3Key: resource.code.s3Key, ImageUri: resource.code.imageUri, + ZipFile: resource.code.functionCodeZip, }).promise(); // only if the code changed is there any point in publishing a new Version @@ -288,3 +305,55 @@ class LambdaFunctionHotswapOperation implements HotswapOperation { return Promise.all(operations); } } + +/** + * Compress a string as a file, returning a promise for the zip buffer + * https://github.com/archiverjs/node-archiver/issues/342 + */ +function zipString(fileName: string, rawString: string): Promise { + return new Promise((resolve, reject) => { + const buffers: Buffer[] = []; + + const converter = new Writable(); + + converter._write = (chunk: Buffer, _: string, callback: () => void) => { + buffers.push(chunk); + process.nextTick(callback); + }; + + converter.on('finish', () => { + resolve(Buffer.concat(buffers)); + }); + + const archive = archiver('zip'); + + archive.on('error', (err) => { + reject(err); + }); + + archive.pipe(converter); + + archive.append(rawString, { + name: fileName, + date: new Date('1980-01-01T00:00:00.000Z'), // Add date to make resulting zip file deterministic + }); + + void archive.finalize(); + }); +} + +/** + * Get file extension from Lambda runtime string. + * We use this extension to create a deployment package from Lambda inline code. + */ +function determineCodeFileExtFromRuntime(runtime: string): string { + if (runtime.startsWith('node')) { + return 'js'; + } + if (runtime.startsWith('python')) { + return 'py'; + } + // Currently inline code only supports Node.js and Python, ignoring other runtimes. + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html#aws-properties-lambda-function-code-properties + throw new CfnEvaluationException(`runtime ${runtime} is unsupported, only node.js and python runtimes are currently supported.`); +} diff --git a/packages/aws-cdk/test/api/hotswap/lambda-functions-inline-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/lambda-functions-inline-hotswap-deployments.test.ts new file mode 100644 index 0000000000000..13554cc655dcb --- /dev/null +++ b/packages/aws-cdk/test/api/hotswap/lambda-functions-inline-hotswap-deployments.test.ts @@ -0,0 +1,148 @@ +import { Lambda } from 'aws-sdk'; +import * as setup from './hotswap-test-setup'; + +let mockUpdateLambdaCode: (params: Lambda.Types.UpdateFunctionCodeRequest) => Lambda.Types.FunctionConfiguration; +let mockTagResource: (params: Lambda.Types.TagResourceRequest) => {}; +let mockUntagResource: (params: Lambda.Types.UntagResourceRequest) => {}; +let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; + +beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + mockUpdateLambdaCode = jest.fn(); + mockTagResource = jest.fn(); + mockUntagResource = jest.fn(); + hotswapMockSdkProvider.stubLambda({ + updateFunctionCode: mockUpdateLambdaCode, + tagResource: mockTagResource, + untagResource: mockUntagResource, + }); +}); + +test('calls the updateLambdaCode() API when it receives only a code difference in a Lambda function (Inline Node.js code)', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + ZipFile: 'exports.handler = () => {return true}', + }, + Runtime: 'nodejs14.x', + FunctionName: 'my-function', + }, + }, + }, + }); + const newCode = 'exports.handler = () => {return false}'; + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + ZipFile: newCode, + }, + Runtime: 'nodejs14.x', + FunctionName: 'my-function', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateLambdaCode).toHaveBeenCalledWith({ + FunctionName: 'my-function', + ZipFile: expect.any(Buffer), + }); +}); + +test('calls the updateLambdaCode() API when it receives only a code difference in a Lambda function (Inline Python code)', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + ZipFile: 'def handler(event, context):\n return True', + }, + Runtime: 'python3.9', + FunctionName: 'my-function', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + ZipFile: 'def handler(event, context):\n return False', + }, + Runtime: 'python3.9', + FunctionName: 'my-function', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateLambdaCode).toHaveBeenCalledWith({ + FunctionName: 'my-function', + ZipFile: expect.any(Buffer), + }); +}); + +test('throw a CfnEvaluationException when it receives an unsupported function runtime', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + ZipFile: 'def handler(event:, context:) true end', + }, + Runtime: 'ruby2.7', + FunctionName: 'my-function', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + ZipFile: 'def handler(event:, context:) false end', + }, + Runtime: 'ruby2.7', + FunctionName: 'my-function', + }, + }, + }, + }, + }); + + // WHEN + const tryHotswap = hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + await expect(tryHotswap).rejects.toThrow('runtime ruby2.7 is unsupported, only node.js and python runtimes are currently supported.'); +});