Skip to content

Commit

Permalink
feat(lambda): currentVersion, version.addAlias() (#6771)
Browse files Browse the repository at this point in the history
It is common for AWS services to require an explicit AWS Lambda Version when referencing functions. When an `AWS::Lambda::Version` resource is defined in CloudFormation is captures the AWS Lambda configuration *at the time of the creation of the version resource. This means that if the function's configuration or code is updated, the Version resource will no longer point to the function defined in the stack.

To address this, we introduce a property `function.currentVersion` which will create a new AWS::Lambda::Version resource every time the function's configuration changes. This is done by encoding a hash of the function's CloudFormation properties into the logical ID of the version resource.

Additionally, this change adds `version.addAlias` which makes it easier to define an AWS Lambda alias for a version.

The result is this:

    fn.currentVersion.addAlias('live');

We employ an approach similar to apigateway's "Deployment" resource in order to implement `currentVersion`: during "prepare", we synthesize the CloudFormation template snippet of the AWS::Lambda::Function resource, calculate an MD5 for it and append it to the logical ID of the version resource.

Resolves #6750
Resolves #5334
  • Loading branch information
Elad Ben-Israel authored Mar 25, 2020
1 parent 5cd2018 commit c94ce62
Show file tree
Hide file tree
Showing 12 changed files with 720 additions and 38 deletions.
63 changes: 63 additions & 0 deletions packages/@aws-cdk/aws-lambda/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,69 @@ to our CDK project directory. This is especially important when we want to share
this construct through a library. Different programming languages will have
different techniques for bundling resources into libraries.

When using `fromAsset` or `fromInline`, you can obtain the hash of source
through the `function.codeHash` property. This property will return `undefined`
if the code hash cannot be calculated during synthesis (e.g. when using code
from an S3 bucket).

### Versions and Aliases

You can use
[versions](https://docs.aws.amazon.com/lambda/latest/dg/configuration-versions.html)
to manage the deployment of your AWS Lambda functions. For example, you can
publish a new version of a function for beta testing without affecting users of
the stable production version.

The function version includes the following information:

- The function code and all associated dependencies.
- The Lambda runtime that executes the function.
- All of the function settings, including the environment variables.
- A unique Amazon Resource Name (ARN) to identify this version of the function.

You can define one or more
[aliases](https://docs.aws.amazon.com/lambda/latest/dg/configuration-aliases.html)
for your AWS Lambda function. A Lambda alias is like a pointer to a specific
Lambda function version. Users can access the function version using the alias
ARN.

The `fn.currentVersion` property can be used to obtain a `lambda.Version`
resource that represents the AWS Lambda function defined in your application.
Any change to your function's code or configuration will result in the creation
of a new version resource. You can specify options for this version through the
`currentVersionOptions` property.

> The `currentVersion` property is only supported when your AWS Lambda function
> uses either `lambda.Code.fromAsset` or `lambda.Code.fromInline`. Other types
> of code providers (such as `lambda.Code.fromBucket`) require that you define a
> `lambda.Version` resource directly since the CDK is unable to determine if
> their contents had changed.
The `version.addAlias()` method can be used to define an AWS Lambda alias that
points to a specific version.

The following example defines an alias named `live` which will always point to a
version that represents the function as defined in your CDK app. When you change
your lambda code or configuration, a new resource will be created. You can
specify options for the current version through the `currentVersionOptions`
property.

```ts
const fn = new lambda.Function(this, 'MyFunction', {
currentVersionOptions: {
removalPolicy: RemovalPolicy.RETAIN, // retain old versions
retryAttempts: 1 // async retry attempts
}
});

fn.currentVersion.addAlias('live');
```

> NOTE: The `fn.latestVersion` property returns a `lambda.IVersion` which
> represents the `$LATEST` pseudo-version. Most AWS services require a specific
> AWS Lambda version, and won't allow you to use `$LATEST`. Therefore, you would
> normally want to use `lambda.currentVersion`.
### Layers

The `lambda.LayerVersion` class can be used to define Lambda layers and manage
Expand Down
33 changes: 19 additions & 14 deletions packages/@aws-cdk/aws-lambda/lib/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,16 @@ export interface IAlias extends IFunction {
}

/**
* Properties for a new Lambda alias
* Options for `lambda.Alias`.
*/
export interface AliasProps extends EventInvokeConfigOptions {
export interface AliasOptions extends EventInvokeConfigOptions {
/**
* Description for the alias
*
* @default No description
*/
readonly description?: string;

/**
* Function version this alias refers to
*
* Use lambda.addVersion() to obtain a new lambda version to refer to.
*/
readonly version: IVersion;

/**
* Name of this alias
*/
readonly aliasName: string;

/**
* Additional versions with individual weights this alias points to
*
Expand Down Expand Up @@ -69,6 +57,23 @@ export interface AliasProps extends EventInvokeConfigOptions {
readonly provisionedConcurrentExecutions?: number;
}

/**
* Properties for a new Lambda alias
*/
export interface AliasProps extends AliasOptions {
/**
* Name of this alias
*/
readonly aliasName: string;

/**
* Function version this alias refers to
*
* Use lambda.addVersion() to obtain a new lambda version to refer to.
*/
readonly version: IVersion;
}

export interface AliasAttributes {
readonly aliasName: string;
readonly aliasVersion: IVersion;
Expand Down
17 changes: 15 additions & 2 deletions packages/@aws-cdk/aws-lambda/lib/function-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import * as cloudwatch from '@aws-cdk/aws-cloudwatch';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as iam from '@aws-cdk/aws-iam';
import { ConstructNode, IResource, Resource } from '@aws-cdk/core';
import { AliasOptions } from './alias';
import { EventInvokeConfig, EventInvokeConfigOptions } from './event-invoke-config';
import { IEventSource } from './event-source';
import { EventSourceMapping, EventSourceMappingOptions } from './event-source-mapping';
import { IVersion } from './lambda-version';
import { CfnPermission } from './lambda.generated';
import { Permission } from './permission';
import { addAlias } from './util';

export interface IFunction extends IResource, ec2.IConnectable, iam.IGrantable {

Expand Down Expand Up @@ -39,6 +41,13 @@ export interface IFunction extends IResource, ec2.IConnectable, iam.IGrantable {

/**
* The `$LATEST` version of this function.
*
* Note that this is reference to a non-specific AWS Lambda version, which
* means the function this version refers to can return different results in
* different invocations.
*
* To obtain a reference to an explicit version which references the current
* function configuration, use `lambdaFunction.currentVersion` instead.
*/
readonly latestVersion: IVersion;

Expand Down Expand Up @@ -102,7 +111,7 @@ export interface IFunction extends IResource, ec2.IConnectable, iam.IGrantable {
/**
* Configures options for asynchronous invocation.
*/
configureAsyncInvoke(options: EventInvokeConfigOptions): void
configureAsyncInvoke(options: EventInvokeConfigOptions): void;
}

/**
Expand Down Expand Up @@ -235,7 +244,7 @@ export abstract class FunctionBase extends Resource implements IFunction {
}

public get latestVersion(): IVersion {
// Dynamic to avoid invinite recursion when creating the LatestVersion instance...
// Dynamic to avoid infinite recursion when creating the LatestVersion instance...
return new LatestVersion(this);
}

Expand Down Expand Up @@ -393,4 +402,8 @@ class LatestVersion extends FunctionBase implements IVersion {
public get role() {
return this.lambda.role;
}

public addAlias(aliasName: string, options: AliasOptions = {}) {
return addAlias(this, this, aliasName, options);
}
}
23 changes: 23 additions & 0 deletions packages/@aws-cdk/aws-lambda/lib/function-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { CfnResource, Stack } from "@aws-cdk/core";
import * as crypto from 'crypto';
import { Function as LambdaFunction } from "./function";

export function calculateFunctionHash(fn: LambdaFunction) {
const stack = Stack.of(fn);

const functionResource = fn.node.defaultChild as CfnResource;

// render the cloudformation resource from this function
const config = stack.resolve((functionResource as any)._toCloudFormation());

const hash = crypto.createHash('md5');
hash.update(JSON.stringify(config));

return hash.digest('hex');
}

export function trimFromStart(s: string, maxLength: number) {
const desiredLength = Math.min(maxLength, s.length);
const newStart = s.length - desiredLength;
return s.substring(newStart);
}
103 changes: 90 additions & 13 deletions packages/@aws-cdk/aws-lambda/lib/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import * as ec2 from '@aws-cdk/aws-ec2';
import * as iam from '@aws-cdk/aws-iam';
import * as logs from '@aws-cdk/aws-logs';
import * as sqs from '@aws-cdk/aws-sqs';
import { Construct, Duration, Fn, Lazy } from '@aws-cdk/core';
import { CfnResource, Construct, Duration, Fn, Lazy, Stack } from '@aws-cdk/core';
import { Code, CodeConfig } from './code';
import { EventInvokeConfigOptions } from './event-invoke-config';
import { IEventSource } from './event-source';
import { FunctionAttributes, FunctionBase, IFunction } from './function-base';
import { Version } from './lambda-version';
import { calculateFunctionHash, trimFromStart } from './function-hash';
import { Version, VersionOptions } from './lambda-version';
import { CfnFunction } from './lambda.generated';
import { ILayerVersion } from './layers';
import { LogRetention } from './log-retention';
Expand Down Expand Up @@ -224,6 +225,13 @@ export interface FunctionOptions extends EventInvokeConfigOptions {
* @default - A new role is created.
*/
readonly logRetentionRole?: iam.IRole;

/**
* Options for the `lambda.Version` resource automatically created by the
* `fn.currentVersion` method.
* @default - default options as described in `VersionOptions`
*/
readonly currentVersionOptions?: VersionOptions;
}

export interface FunctionProps extends FunctionOptions {
Expand Down Expand Up @@ -266,6 +274,28 @@ export interface FunctionProps extends FunctionOptions {
* library.
*/
export class Function extends FunctionBase {

/**
* Returns a `lambda.Version` which represents the current version of this
* Lambda function. A new version will be created every time the function's
* configuration changes.
*
* You can specify options for this version using the `currentVersionOptions`
* prop when initializing the `lambda.Function`.
*/
public get currentVersion(): Version {
if (this._currentVersion) {
return this._currentVersion;
}

this._currentVersion = new Version(this, `CurrentVersion`, {
lambda: this,
...this.currentVersionOptions
});

return this._currentVersion;
}

public static fromFunctionArn(scope: Construct, id: string, functionArn: string): IFunction {
return Function.fromFunctionAttributes(scope, id, { functionArn });
}
Expand Down Expand Up @@ -425,6 +455,9 @@ export class Function extends FunctionBase {
*/
private readonly environment: { [key: string]: string };

private readonly currentVersionOptions?: VersionOptions;
private _currentVersion?: Version;

constructor(scope: Construct, id: string, props: FunctionProps) {
super(scope, id, {
physicalName: props.functionName,
Expand Down Expand Up @@ -520,6 +553,8 @@ export class Function extends FunctionBase {
retryAttempts: props.retryAttempts,
});
}

this.currentVersionOptions = props.currentVersionOptions;
}

/**
Expand Down Expand Up @@ -557,26 +592,35 @@ export class Function extends FunctionBase {
* Add a new version for this Lambda
*
* If you want to deploy through CloudFormation and use aliases, you need to
* add a new version (with a new name) to your Lambda every time you want
* to deploy an update. An alias can then refer to the newly created Version.
* add a new version (with a new name) to your Lambda every time you want to
* deploy an update. An alias can then refer to the newly created Version.
*
* All versions should have distinct names, and you should not delete versions
* as long as your Alias needs to refer to them.
*
* @param name A unique name for this version
* @param codeSha256 The SHA-256 hash of the most recently deployed Lambda source code, or
* omit to skip validation.
* @param name A unique name for this version.
* @param codeSha256 The SHA-256 hash of the most recently deployed Lambda
* source code, or omit to skip validation.
* @param description A description for this version.
* @param provisionedExecutions A provisioned concurrency configuration for a function's version.
* @param asyncInvokeConfig configuration for this version when it is invoked asynchronously.
* @param provisionedExecutions A provisioned concurrency configuration for a
* function's version.
* @param asyncInvokeConfig configuration for this version when it is invoked
* asynchronously.
* @returns A new Version object.
*
* @deprecated This method will create an AWS::Lambda::Version resource which
* snapshots the AWS Lambda function *at the time of its creation* and it
* won't get updated when the function changes. Instead, use
* `this.currentVersion` to obtain a reference to a version resource that gets
* automatically recreated when the function configuration (or code) changes.
*/
public addVersion(
name: string,
codeSha256?: string,
description?: string,
provisionedExecutions?: number,
asyncInvokeConfig: EventInvokeConfigOptions = {}): Version {

return new Version(this, 'Version' + name, {
lambda: this,
codeSha256,
Expand Down Expand Up @@ -607,14 +651,47 @@ export class Function extends FunctionBase {
return this._logGroup;
}

protected prepare() {
super.prepare();

// if we have a current version resource, override it's logical id
// so that it includes the hash of the function code and it's configuration.
if (this._currentVersion) {
const stack = Stack.of(this);
const cfn = this._currentVersion.node.defaultChild as CfnResource;
const originalLogicalId: string = stack.resolve(cfn.logicalId);

const hash = calculateFunctionHash(this);

const logicalId = trimFromStart(originalLogicalId, 255 - 32);
cfn.overrideLogicalId(`${logicalId}${hash}`);
}
}

private renderEnvironment() {
if (!this.environment || Object.keys(this.environment).length === 0) {
return undefined;
}

return {
variables: this.environment
};
// for backwards compatibility we do not sort environment variables in case
// _currentVersion is not defined. otherwise, this would have invalidated
// the template, and for example, may cause unneeded updates for nested
// stacks.
if (!this._currentVersion) {
return {
variables: this.environment
};
}

// sort environment so the hash of the function used to create
// `currentVersion` is not affected by key order (this is how lambda does
// it).
const variables: { [key: string]: string } = { };
for (const key of Object.keys(this.environment).sort()) {
variables[key] = this.environment[key];
}

return { variables };
}

/**
Expand Down Expand Up @@ -749,4 +826,4 @@ export function verifyCodeConfig(code: CodeConfig, runtime: Runtime) {
if (code.inlineCode && !runtime.supportsInlineCode) {
throw new Error(`Inline source not allowed for ${runtime.name}`);
}
}
}
Loading

0 comments on commit c94ce62

Please sign in to comment.