packages/@aws-cdk/toolkit-lib/lib/api/hotswap/lambda-functions.ts (279 lines of code) (raw):
import { Writable } from 'stream';
import type { PropertyDifference } from '@aws-cdk/cloudformation-diff';
import type { FunctionConfiguration, UpdateFunctionConfigurationCommandInput } from '@aws-sdk/client-lambda';
import type { HotswapChange } from './common';
import { classifyChanges } from './common';
import type { AffectedResource, ResourceChange } from '../../payloads/hotswap';
import { flatMap } from '../../util';
import type { ILambdaClient, SDK } from '../aws-auth/private';
import { CfnEvaluationException, type EvaluateCloudFormationTemplate } from '../cloudformation';
import { ToolkitError } from '../toolkit-error';
// namespace object imports won't work in the bundle for function exports
// eslint-disable-next-line @typescript-eslint/no-require-imports
const archiver = require('archiver');
export async function isHotswappableLambdaFunctionChange(
logicalId: string,
change: ResourceChange,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<HotswapChange[]> {
// if the change is for a Lambda Version, we just ignore it
// we will publish a new version when we get to hotswapping the actual Function this Version points to
// (Versions can't be changed in CloudFormation anyway, they're immutable)
if (change.newValue.Type === 'AWS::Lambda::Version') {
return [];
}
// we handle Aliases specially too
// the actual alias update will happen if we change the function
if (change.newValue.Type === 'AWS::Lambda::Alias') {
return classifyAliasChanges(change);
}
if (change.newValue.Type !== 'AWS::Lambda::Function') {
return [];
}
const ret: HotswapChange[] = [];
const classifiedChanges = classifyChanges(change, ['Code', 'Environment', 'Description']);
classifiedChanges.reportNonHotswappablePropertyChanges(ret);
const functionName = await evaluateCfnTemplate.establishResourcePhysicalName(
logicalId,
change.newValue.Properties?.FunctionName,
);
const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps);
if (functionName && namesOfHotswappableChanges.length > 0) {
const lambdaCodeChange = await evaluateLambdaFunctionProps(
classifiedChanges.hotswappableProps,
change.newValue.Properties?.Runtime,
evaluateCfnTemplate,
);
// nothing to do here
if (lambdaCodeChange === undefined) {
return ret;
}
const dependencies = await dependantResources(logicalId, functionName, evaluateCfnTemplate);
ret.push({
change: {
cause: change,
resources: [
{
logicalId,
resourceType: change.newValue.Type,
physicalName: functionName,
metadata: evaluateCfnTemplate.metadataFor(logicalId),
},
...dependencies,
],
},
hotswappable: true,
service: 'lambda',
apply: async (sdk: SDK) => {
const lambda = sdk.lambda();
const operations: Promise<any>[] = [];
if (lambdaCodeChange.code !== undefined || lambdaCodeChange.configurations !== undefined) {
if (lambdaCodeChange.code !== undefined) {
const updateFunctionCodeResponse = await lambda.updateFunctionCode({
FunctionName: functionName,
S3Bucket: lambdaCodeChange.code.s3Bucket,
S3Key: lambdaCodeChange.code.s3Key,
ImageUri: lambdaCodeChange.code.imageUri,
ZipFile: lambdaCodeChange.code.functionCodeZip,
S3ObjectVersion: lambdaCodeChange.code.s3ObjectVersion,
});
await waitForLambdasPropertiesUpdateToFinish(updateFunctionCodeResponse, lambda, functionName);
}
if (lambdaCodeChange.configurations !== undefined) {
const updateRequest: UpdateFunctionConfigurationCommandInput = {
FunctionName: functionName,
};
if (lambdaCodeChange.configurations.description !== undefined) {
updateRequest.Description = lambdaCodeChange.configurations.description;
}
if (lambdaCodeChange.configurations.environment !== undefined) {
updateRequest.Environment = lambdaCodeChange.configurations.environment;
}
const updateFunctionCodeResponse = await lambda.updateFunctionConfiguration(updateRequest);
await waitForLambdasPropertiesUpdateToFinish(updateFunctionCodeResponse, lambda, functionName);
}
// only if the code changed is there any point in publishing a new Version
const versions = dependencies.filter((d) => d.resourceType === 'AWS::Lambda::Version');
if (versions.length) {
const publishVersionPromise = lambda.publishVersion({
FunctionName: functionName,
});
const aliases = dependencies.filter((d) => d.resourceType === 'AWS::Lambda::Alias');
if (aliases.length) {
// we need to wait for the Version to finish publishing
const versionUpdate = await publishVersionPromise;
for (const alias of aliases) {
operations.push(
lambda.updateAlias({
FunctionName: functionName,
Name: alias.physicalName,
FunctionVersion: versionUpdate.Version,
}),
);
}
} else {
operations.push(publishVersionPromise);
}
}
}
// run all of our updates in parallel
// Limited set of updates per function
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
await Promise.all(operations);
},
});
}
return ret;
}
/**
* Determines which changes to this Alias are hotswappable or not
*/
function classifyAliasChanges(change: ResourceChange): HotswapChange[] {
const ret: HotswapChange[] = [];
const classifiedChanges = classifyChanges(change, ['FunctionVersion']);
classifiedChanges.reportNonHotswappablePropertyChanges(ret);
// we only want to report not hotswappable changes to aliases
// the actual alias update will happen if we change the function
return ret;
}
/**
* Evaluates the hotswappable properties of an AWS::Lambda::Function and
* Returns a `LambdaFunctionChange` if the change is hotswappable.
* Returns `undefined` if the change is not hotswappable.
*/
async function evaluateLambdaFunctionProps(
hotswappablePropChanges: Record<string, PropertyDifference<any>>,
runtime: string,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<LambdaFunctionChange | undefined> {
/*
* At first glance, we would want to initialize these using the "previous" values (change.oldValue),
* in case only one of them changed, like the key, and the Bucket stayed the same.
* However, that actually fails for old-style synthesis, which uses CFN Parameters!
* Because the names of the Parameters depend on the hash of the Asset,
* the Parameters used for the "old" values no longer exist in `assetParams` at this point,
* which means we don't have the correct values available to evaluate the CFN expression with.
* Fortunately, the diff will always include both the s3Bucket and s3Key parts of the Lambda's Code property,
* even if only one of them was actually changed,
* which means we don't need the "old" values at all, and we can safely initialize these with just `''`.
*/
let code: LambdaFunctionCode | undefined = undefined;
let description: string | undefined = undefined;
let environment: { [key: string]: string } | undefined = undefined;
for (const updatedPropName in hotswappablePropChanges) {
const updatedProp = hotswappablePropChanges[updatedPropName];
switch (updatedPropName) {
case 'Code':
let s3Bucket, s3Key, s3ObjectVersion, imageUri, functionCodeZip;
for (const newPropName in updatedProp.newValue) {
switch (newPropName) {
case 'S3Bucket':
s3Bucket = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
break;
case 'S3Key':
s3Key = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
break;
case 'S3ObjectVersion':
s3ObjectVersion = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
break;
case 'ImageUri':
imageUri = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]);
break;
case 'ZipFile':
// 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(runtime);
if (!functionRuntime) {
return undefined;
}
// file extension must be chosen depending on the runtime
const codeFileExt = determineCodeFileExtFromRuntime(functionRuntime);
functionCodeZip = await zipString(`index.${codeFileExt}`, functionCode);
break;
}
}
code = {
s3Bucket,
s3Key,
s3ObjectVersion,
imageUri,
functionCodeZip,
};
break;
case 'Description':
description = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue);
break;
case 'Environment':
environment = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue);
break;
default:
// we will never get here, but just in case we do throw an error
throw new ToolkitError(
'while apply()ing, found a property that cannot be hotswapped. Please report this at github.com/aws/aws-cdk/issues/new/choose',
);
}
}
const configurations = description || environment ? { description, environment } : undefined;
return code || configurations ? { code, configurations } : undefined;
}
interface LambdaFunctionCode {
readonly s3Bucket?: string;
readonly s3Key?: string;
readonly s3ObjectVersion?: string;
readonly imageUri?: string;
readonly functionCodeZip?: Buffer;
}
interface LambdaFunctionConfigurations {
readonly description?: string;
readonly environment?: { [key: string]: string };
}
interface LambdaFunctionChange {
readonly code?: LambdaFunctionCode;
readonly configurations?: LambdaFunctionConfigurations;
}
/**
* 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: any) => {
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();
});
}
/**
* After a Lambda Function is updated, it cannot be updated again until the
* `State=Active` and the `LastUpdateStatus=Successful`.
*
* Depending on the configuration of the Lambda Function this could happen relatively quickly
* 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).
*/
async function waitForLambdasPropertiesUpdateToFinish(
currentFunctionConfiguration: FunctionConfiguration,
lambda: ILambdaClient,
functionName: string,
): Promise<void> {
const functionIsInVpcOrUsesDockerForCode =
currentFunctionConfiguration.VpcConfig?.VpcId || currentFunctionConfiguration.PackageType === 'Image';
// if the function is deployed in a VPC or if it is a container image function
// then the update will take much longer and we can wait longer between checks
// otherwise, the update will be quick, so a 1-second delay is fine
const delaySeconds = functionIsInVpcOrUsesDockerForCode ? 5 : 1;
await lambda.waitUntilFunctionUpdated(delaySeconds, {
FunctionName: functionName,
});
}
/**
* 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.`,
);
}
/**
* Finds all Versions that reference an AWS::Lambda::Function with logical ID `logicalId`
* and Aliases that reference those Versions.
*/
async function versionsAndAliases(logicalId: string, evaluateCfnTemplate: EvaluateCloudFormationTemplate) {
// find all Lambda Versions that reference this Function
const versionsReferencingFunction = evaluateCfnTemplate
.findReferencesTo(logicalId)
.filter((r) => r.Type === 'AWS::Lambda::Version');
// find all Lambda Aliases that reference the above Versions
const aliasesReferencingVersions = flatMap(versionsReferencingFunction, v =>
evaluateCfnTemplate.findReferencesTo(v.LogicalId));
return { versionsReferencingFunction, aliasesReferencingVersions };
}
async function dependantResources(
logicalId: string,
functionName: string,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<Array<AffectedResource>> {
const candidates = await versionsAndAliases(logicalId, evaluateCfnTemplate);
// Limited set of updates per function
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
const aliases = await Promise.all(candidates.aliasesReferencingVersions.map(async (a) => {
const name = await evaluateCfnTemplate.evaluateCfnExpression(a.Properties?.Name);
return {
logicalId: a.LogicalId,
resourceType: a.Type,
physicalName: name,
description: `${a.Type} '${name}' for AWS::Lambda::Function '${functionName}'`,
metadata: evaluateCfnTemplate.metadataFor(a.LogicalId),
};
}));
const versions = candidates.versionsReferencingFunction.map((v) => (
{
logicalId: v.LogicalId,
resourceType: v.Type,
description: `${v.Type} for AWS::Lambda::Function '${functionName}'`,
metadata: evaluateCfnTemplate.metadataFor(v.LogicalId),
}
));
return [
...versions,
...aliases,
];
}