Skip to content

Commit

Permalink
feat(cli): support hotswapping Lambda functions with inline code (#18408
Browse files Browse the repository at this point in the history
)

Similarly to #18319, this PR supports hotswap of Lambda functions that use `InlineCode`.

`InlineCode` uses [CloudFormation `ZipFile` feature](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html#:~:text=requires%3A%20No%20interruption-,ZipFile,-\(Node.js%20and). To emulate its behavior, we create a zip file of the provided inline code with its filename `index.js` or `index.py` according to the runtime (CFn only supports python or nodejs for ZipFile), and pass the file's binary buffer to Lambda API.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
tmokmss authored Jan 19, 2022
1 parent 2b0b5ea commit d0b8512
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 3 deletions.
2 changes: 1 addition & 1 deletion packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
73 changes: 71 additions & 2 deletions packages/aws-cdk/lib/api/hotswap/lambda-functions.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand All @@ -133,6 +147,7 @@ async function isLambdaFunctionCodeOnlyChange(
s3Bucket,
s3Key,
imageUri,
functionCodeZip,
};
}
break;
Expand Down Expand Up @@ -173,6 +188,7 @@ interface LambdaFunctionCode {
readonly s3Bucket?: string;
readonly s3Key?: string;
readonly imageUri?: string;
readonly functionCodeZip?: Buffer;
}

enum TagDeletion {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Buffer> {
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.`);
}
Original file line number Diff line number Diff line change
@@ -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.');
});

0 comments on commit d0b8512

Please sign in to comment.