From 02ced10739ecacc9ca39e9e0b563ddfbf5d0b245 Mon Sep 17 00:00:00 2001 From: Niranjan Jayakar Date: Tue, 1 Dec 2020 16:53:53 +0000 Subject: [PATCH] feat(lambda): container images (#11809) Adds support for deploying ECR container images as Lambda function handlers. --- packages/@aws-cdk/aws-lambda/README.md | 26 +++ packages/@aws-cdk/aws-lambda/lib/code.ts | 174 +++++++++++++++++- packages/@aws-cdk/aws-lambda/lib/function.ts | 53 +++++- packages/@aws-cdk/aws-lambda/lib/handler.ts | 11 ++ .../@aws-cdk/aws-lambda/lib/image-function.ts | 71 +++++++ packages/@aws-cdk/aws-lambda/lib/index.ts | 2 + packages/@aws-cdk/aws-lambda/lib/runtime.ts | 5 + packages/@aws-cdk/aws-lambda/package.json | 14 +- .../@aws-cdk/aws-lambda/test/code.test.ts | 140 +++++++++++++- .../test/docker-lambda-handler/Dockerfile | 8 + .../test/docker-lambda-handler/app.ts | 9 + .../@aws-cdk/aws-lambda/test/function.test.ts | 140 ++++++++++++++ .../test/integ.lambda.docker.expected.json | 71 +++++++ .../aws-lambda/test/integ.lambda.docker.ts | 18 ++ .../490_Lambda_Containers_patch.json | 79 ++++++++ 15 files changed, 799 insertions(+), 22 deletions(-) create mode 100644 packages/@aws-cdk/aws-lambda/lib/handler.ts create mode 100644 packages/@aws-cdk/aws-lambda/lib/image-function.ts create mode 100644 packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/Dockerfile create mode 100644 packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/app.ts create mode 100644 packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.expected.json create mode 100644 packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.ts create mode 100644 packages/@aws-cdk/cfnspec/spec-source/490_Lambda_Containers_patch.json diff --git a/packages/@aws-cdk/aws-lambda/README.md b/packages/@aws-cdk/aws-lambda/README.md index 142508626a591..ea1d256c976bc 100644 --- a/packages/@aws-cdk/aws-lambda/README.md +++ b/packages/@aws-cdk/aws-lambda/README.md @@ -50,6 +50,32 @@ 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. +### Docker Images + +Lambda functions allow specifying their handlers within docker images. The docker +image can be an image from ECR or a local asset that the CDK will package and load +into ECR. + +The following `DockerImageFunction` construct uses a local folder with a +Dockerfile as the asset that will be used as the function handler. + +```ts +new lambda.DockerImageFunction(this, 'AssetFunction', { + code: DockerImageCode.fromAssetImage(path.join(__dirname, 'docker-handler')), +}); +``` + +You can also specify an image that already exists in ECR as the function handler. + +```ts +import * as ecr from '@aws-cdk/aws-ecr'; +const repo = new ecr.Repository(this, 'Repository'); + +new lambda.DockerImageFunction(this, 'ECRFunction', { + code: DockerImageCode.fromEcrImage(repo), +}); +``` + ### Execution Role Lambda functions assume an IAM role during execution. In CDK by default, Lambda diff --git a/packages/@aws-cdk/aws-lambda/lib/code.ts b/packages/@aws-cdk/aws-lambda/lib/code.ts index 263e67f415bd4..fef200a9a6c9c 100644 --- a/packages/@aws-cdk/aws-lambda/lib/code.ts +++ b/packages/@aws-cdk/aws-lambda/lib/code.ts @@ -1,10 +1,16 @@ +import * as ecr from '@aws-cdk/aws-ecr'; +import * as ecr_assets from '@aws-cdk/aws-ecr-assets'; +import * as iam from '@aws-cdk/aws-iam'; import * as s3 from '@aws-cdk/aws-s3'; import * as s3_assets from '@aws-cdk/aws-s3-assets'; import * as cdk from '@aws-cdk/core'; +/** + * Represents the Lambda Handler Code. + */ export abstract class Code { /** - * @returns `LambdaS3Code` associated with the specified S3 object. + * Lambda handler code as an S3 object. * @param bucket The S3 bucket * @param key The object key * @param objectVersion Optional S3 object version @@ -14,6 +20,7 @@ export abstract class Code { } /** + * DEPRECATED * @deprecated use `fromBucket` */ public static bucket(bucket: s3.IBucket, key: string, objectVersion?: string): S3Code { @@ -21,6 +28,7 @@ export abstract class Code { } /** + * Inline code for Lambda handler * @returns `LambdaInlineCode` with inline code. * @param code The actual handler code (limited to 4KiB) */ @@ -29,6 +37,7 @@ export abstract class Code { } /** + * DEPRECATED * @deprecated use `fromInline` */ public static inline(code: string): InlineCode { @@ -45,6 +54,7 @@ export abstract class Code { } /** + * DEPRECATED * @deprecated use `fromAsset` */ public static asset(path: string): AssetCode { @@ -62,12 +72,31 @@ export abstract class Code { } /** + * DEPRECATED * @deprecated use `fromCfnParameters` */ public static cfnParameters(props?: CfnParametersCodeProps): CfnParametersCode { return this.fromCfnParameters(props); } + /** + * Use an existing ECR image as the Lambda code. + * @param repository the ECR repository that the image is in + * @param props properties to further configure the selected image + */ + public static fromEcrImage(repository: ecr.IRepository, props?: EcrImageCodeProps) { + return new EcrImageCode(repository, props); + } + + /** + * Create an ECR image from the specified asset and bind it as the Lambda code. + * @param directory the directory from which the asset must be created + * @param props properties to further configure the selected image + */ + public static fromAssetImage(directory: string, props: AssetImageCodeProps = {}) { + return new AssetImageCode(directory, props); + } + /** * Determines whether this Code is inline code or not. * @@ -95,16 +124,54 @@ export abstract class Code { } } +/** + * Result of binding `Code` into a `Function`. + */ export interface CodeConfig { /** - * The location of the code in S3 (mutually exclusive with `inlineCode`). + * The location of the code in S3 (mutually exclusive with `inlineCode` and `image`). + * @default - code is not an s3 location */ readonly s3Location?: s3.Location; /** - * Inline code (mutually exclusive with `s3Location`). + * Inline code (mutually exclusive with `s3Location` and `image`). + * @default - code is not inline code */ readonly inlineCode?: string; + + /** + * Docker image configuration (mutually exclusive with `s3Location` and `inlineCode`). + * @default - code is not an ECR container image + */ + readonly image?: CodeImageConfig; +} + +/** + * Result of the bind when an ECR image is used. + */ +export interface CodeImageConfig { + /** + * URI to the Docker image. + */ + readonly imageUri: string; + + /** + * Specify or override the CMD on the specified Docker image or Dockerfile. + * This needs to be in the 'exec form', viz., `[ 'executable', 'param1', 'param2' ]`. + * @see https://docs.docker.com/engine/reference/builder/#cmd + * @default - use the CMD specified in the docker image or Dockerfile. + */ + readonly cmd?: string[]; + + /** + * Specify or override the ENTRYPOINT on the specified Docker image or Dockerfile. + * An ENTRYPOINT allows you to configure a container that will run as an executable. + * This needs to be in the 'exec form', viz., `[ 'executable', 'param1', 'param2' ]`. + * @see https://docs.docker.com/engine/reference/builder/#entrypoint + * @default - use the ENTRYPOINT in the docker image or Dockerfile. + */ + readonly entrypoint?: string[]; } /** @@ -313,3 +380,104 @@ export class CfnParametersCode extends Code { } } } + +/** + * Properties to initialize a new EcrImageCode + */ +export interface EcrImageCodeProps { + /** + * Specify or override the CMD on the specified Docker image or Dockerfile. + * This needs to be in the 'exec form', viz., `[ 'executable', 'param1', 'param2' ]`. + * @see https://docs.docker.com/engine/reference/builder/#cmd + * @default - use the CMD specified in the docker image or Dockerfile. + */ + readonly cmd?: string[]; + + /** + * Specify or override the ENTRYPOINT on the specified Docker image or Dockerfile. + * An ENTRYPOINT allows you to configure a container that will run as an executable. + * This needs to be in the 'exec form', viz., `[ 'executable', 'param1', 'param2' ]`. + * @see https://docs.docker.com/engine/reference/builder/#entrypoint + * @default - use the ENTRYPOINT in the docker image or Dockerfile. + */ + readonly entrypoint?: string[]; + + /** + * The image tag to use when pulling the image from ECR. + * @default 'latest' + */ + readonly tag?: string; +} + +/** + * Represents a Docker image in ECR that can be bound as Lambda Code. + */ +export class EcrImageCode extends Code { + public readonly isInline: boolean = false; + + constructor(private readonly repository: ecr.IRepository, private readonly props: EcrImageCodeProps = {}) { + super(); + } + + public bind(_: cdk.Construct): CodeConfig { + this.repository.grantPull(new iam.ServicePrincipal('lambda.amazonaws.com')); + + return { + image: { + imageUri: this.repository.repositoryUriForTag(this.props?.tag ?? 'latest'), + cmd: this.props.cmd, + entrypoint: this.props.entrypoint, + }, + }; + } +} + +/** + * Properties to initialize a new AssetImage + */ +export interface AssetImageCodeProps extends ecr_assets.DockerImageAssetOptions { + /** + * Specify or override the CMD on the specified Docker image or Dockerfile. + * This needs to be in the 'exec form', viz., `[ 'executable', 'param1', 'param2' ]`. + * @see https://docs.docker.com/engine/reference/builder/#cmd + * @default - use the CMD specified in the docker image or Dockerfile. + */ + readonly cmd?: string[]; + + /** + * Specify or override the ENTRYPOINT on the specified Docker image or Dockerfile. + * An ENTRYPOINT allows you to configure a container that will run as an executable. + * This needs to be in the 'exec form', viz., `[ 'executable', 'param1', 'param2' ]`. + * @see https://docs.docker.com/engine/reference/builder/#entrypoint + * @default - use the ENTRYPOINT in the docker image or Dockerfile. + */ + readonly entrypoint?: string[]; +} + +/** + * Represents an ECR image that will be constructed from the specified asset and can be bound as Lambda code. + */ +export class AssetImageCode extends Code { + public readonly isInline: boolean = false; + + constructor(private readonly directory: string, private readonly props: AssetImageCodeProps) { + super(); + } + + public bind(scope: cdk.Construct): CodeConfig { + const asset = new ecr_assets.DockerImageAsset(scope, 'AssetImage', { + directory: this.directory, + ...this.props, + }); + + asset.repository.grantPull(new iam.ServicePrincipal('lambda.amazonaws.com')); + + return { + image: { + imageUri: asset.imageUri, + entrypoint: this.props.entrypoint, + cmd: this.props.cmd, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-lambda/lib/function.ts b/packages/@aws-cdk/aws-lambda/lib/function.ts index d2000fd32d342..d69a76c0639a5 100644 --- a/packages/@aws-cdk/aws-lambda/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda/lib/function.ts @@ -12,6 +12,7 @@ import { IEventSource } from './event-source'; import { FileSystem } from './filesystem'; import { FunctionAttributes, FunctionBase, IFunction } from './function-base'; import { calculateFunctionHash, trimFromStart } from './function-hash'; +import { Handler } from './handler'; import { Version, VersionOptions } from './lambda-version'; import { CfnFunction } from './lambda.generated'; import { ILayerVersion } from './layers'; @@ -288,6 +289,8 @@ export interface FunctionProps extends FunctionOptions { * The runtime environment for the Lambda function that you are uploading. * For valid values, see the Runtime property in the AWS Lambda Developer * Guide. + * + * Use `Runtime.FROM_IMAGE` when when defining a function from a Docker image. */ readonly runtime: Runtime; @@ -304,6 +307,8 @@ export interface FunctionProps extends FunctionOptions { * namespaces and other qualifiers, depending on the runtime. * For more information, see https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-features.html#gettingstarted-features-programmingmodel. * + * Use `Handler.FROM_IMAGE` when defining a function from a Docker image. + * * NOTE: If you specify your source code as inline text by specifying the * ZipFile property within the Code property, specify index.function_name as * the handler. @@ -557,7 +562,7 @@ export class Function extends FunctionBase { } const code = props.code.bind(this); - verifyCodeConfig(code, props.runtime); + verifyCodeConfig(code, props); let profilingGroupEnvironmentVariables: { [key: string]: string } = {}; if (props.profilingGroup && props.profiling !== false) { @@ -590,6 +595,8 @@ export class Function extends FunctionBase { this.deadLetterQueue = this.buildDeadLetterQueue(props); + const UNDEFINED_MARKER = '$$$undefined'; + const resource: CfnFunction = new CfnFunction(this, 'Resource', { functionName: this.physicalName, description: props.description, @@ -598,11 +605,13 @@ export class Function extends FunctionBase { s3Key: code.s3Location && code.s3Location.objectKey, s3ObjectVersion: code.s3Location && code.s3Location.objectVersion, zipFile: code.inlineCode, + imageUri: code.image?.imageUri, }, layers: Lazy.list({ produce: () => this.layers.map(layer => layer.layerVersionArn) }, { omitEmpty: true }), - handler: props.handler, + handler: props.handler === Handler.FROM_IMAGE ? UNDEFINED_MARKER : props.handler, timeout: props.timeout && props.timeout.toSeconds(), - runtime: props.runtime.name, + packageType: props.runtime === Runtime.FROM_IMAGE ? 'Image' : undefined, + runtime: props.runtime === Runtime.FROM_IMAGE ? UNDEFINED_MARKER : props.runtime?.name, role: this.role.roleArn, // Uncached because calling '_checkEdgeCompatibility', which gets called in the resolve of another // Token, actually *modifies* the 'environment' map. @@ -612,8 +621,21 @@ export class Function extends FunctionBase { deadLetterConfig: this.buildDeadLetterConfig(this.deadLetterQueue), tracingConfig: this.buildTracingConfig(props), reservedConcurrentExecutions: props.reservedConcurrentExecutions, + imageConfig: undefinedIfNoKeys({ + command: code.image?.cmd, + entryPoint: code.image?.entrypoint, + }), }); + // since patching the CFN spec to make Runtime and Handler optional causes a + // change in the order of the JSON keys, which results in a change of + // function hash (and invalidation of all lambda functions everywhere), we + // are using a marker to indicate this fields needs to be erased using an + // escape hatch. this should be fixed once the new spec is published and a + // patch is no longer needed. + if (resource.runtime === UNDEFINED_MARKER) { resource.addPropertyOverride('Runtime', undefined); } + if (resource.handler === UNDEFINED_MARKER) { resource.addPropertyOverride('Handler', undefined); } + resource.node.addDependency(this.role); this.functionName = this.getResourceNameAttribute(resource.ref); @@ -966,14 +988,29 @@ function extractNameFromArn(arn: string) { return Fn.select(6, Fn.split(':', arn)); } -export function verifyCodeConfig(code: CodeConfig, runtime: Runtime) { +export function verifyCodeConfig(code: CodeConfig, props: FunctionProps) { // mutually exclusive - if ((!code.inlineCode && !code.s3Location) || (code.inlineCode && code.s3Location)) { - throw new Error('lambda.Code must specify one of "inlineCode" or "s3Location" but not both'); + const codeType = [code.inlineCode, code.s3Location, code.image]; + + if (codeType.filter(x => !!x).length !== 1) { + throw new Error('lambda.Code must specify exactly one of: "inlineCode", "s3Location", or "image"'); + } + + if (!!code.image === (props.handler !== Handler.FROM_IMAGE)) { + throw new Error('handler must be `Handler.FROM_IMAGE` when using image asset for Lambda function'); + } + + if (!!code.image === (props.runtime !== Runtime.FROM_IMAGE)) { + throw new Error('runtime must be `Runtime.FROM_IMAGE` when using image asset for Lambda function'); } // if this is inline code, check that the runtime supports - if (code.inlineCode && !runtime.supportsInlineCode) { - throw new Error(`Inline source not allowed for ${runtime.name}`); + if (code.inlineCode && !props.runtime.supportsInlineCode) { + throw new Error(`Inline source not allowed for ${props.runtime!.name}`); } } + +function undefinedIfNoKeys(struct: A): A | undefined { + const allUndefined = Object.values(struct).every(val => val === undefined); + return allUndefined ? undefined : struct; +} diff --git a/packages/@aws-cdk/aws-lambda/lib/handler.ts b/packages/@aws-cdk/aws-lambda/lib/handler.ts new file mode 100644 index 0000000000000..7288345f5fad5 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/lib/handler.ts @@ -0,0 +1,11 @@ +/** + * Lambda function handler + */ +export class Handler { + /** + * A special handler when the function handler is part of a Docker image. + */ + public static readonly FROM_IMAGE = 'FROM_IMAGE'; + + private constructor() {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/lib/image-function.ts b/packages/@aws-cdk/aws-lambda/lib/image-function.ts new file mode 100644 index 0000000000000..b53b6216894bf --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/lib/image-function.ts @@ -0,0 +1,71 @@ +import * as ecr from '@aws-cdk/aws-ecr'; +import { Construct } from 'constructs'; +import { AssetImageCode, AssetImageCodeProps, EcrImageCode, EcrImageCodeProps, Code } from './code'; +import { Function, FunctionOptions } from './function'; +import { Handler } from './handler'; +import { Runtime } from './runtime'; + +/** + * Properties to configure a new DockerImageFunction construct. + */ +export interface DockerImageFunctionProps extends FunctionOptions { + /** + * The source code of your Lambda function. You can point to a file in an + * Amazon Simple Storage Service (Amazon S3) bucket or specify your source + * code as inline text. + */ + readonly code: DockerImageCode; +} + +/** + * Code property for the DockerImageFunction construct + */ +export abstract class DockerImageCode { + /** + * Use an existing ECR image as the Lambda code. + * @param repository the ECR repository that the image is in + * @param props properties to further configure the selected image + * @experimental + */ + public static fromEcr(repository: ecr.IRepository, props?: EcrImageCodeProps): DockerImageCode { + return { + _bind() { + return new EcrImageCode(repository, props); + }, + }; + } + + /** + * Create an ECR image from the specified asset and bind it as the Lambda code. + * @param directory the directory from which the asset must be created + * @param props properties to further configure the selected image + * @experimental + */ + public static fromImageAsset(directory: string, props: AssetImageCodeProps = {}): DockerImageCode { + return { + _bind() { + return new AssetImageCode(directory, props); + }, + }; + } + + /** + * Produce a `Code` instance from this `DockerImageCode`. + * @internal + */ + public abstract _bind(): Code; +} + +/** + * Create a lambda function where the handler is a docker image + */ +export class DockerImageFunction extends Function { + constructor(scope: Construct, id: string, props: DockerImageFunctionProps) { + super(scope, id, { + ...props, + handler: Handler.FROM_IMAGE, + runtime: Runtime.FROM_IMAGE, + code: props.code._bind(), + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/lib/index.ts b/packages/@aws-cdk/aws-lambda/lib/index.ts index 3581a40cdf535..1ba17427c5210 100644 --- a/packages/@aws-cdk/aws-lambda/lib/index.ts +++ b/packages/@aws-cdk/aws-lambda/lib/index.ts @@ -2,6 +2,8 @@ export * from './alias'; export * from './dlq'; export * from './function-base'; export * from './function'; +export * from './handler'; +export * from './image-function'; export * from './layers'; export * from './permission'; export * from './runtime'; diff --git a/packages/@aws-cdk/aws-lambda/lib/runtime.ts b/packages/@aws-cdk/aws-lambda/lib/runtime.ts index 56ffbecd19b3b..1930641f45f04 100644 --- a/packages/@aws-cdk/aws-lambda/lib/runtime.ts +++ b/packages/@aws-cdk/aws-lambda/lib/runtime.ts @@ -162,6 +162,11 @@ export class Runtime { */ public static readonly PROVIDED_AL2 = new Runtime('provided.al2', RuntimeFamily.OTHER); + /** + * A special runtime entry to be used when function is using a docker image. + */ + public static readonly FROM_IMAGE = new Runtime('FROM_IMAGE'); + /** * The name of this runtime, as expected by the Lambda resource. */ diff --git a/packages/@aws-cdk/aws-lambda/package.json b/packages/@aws-cdk/aws-lambda/package.json index 63319fe238f8f..475f864d4c7b4 100644 --- a/packages/@aws-cdk/aws-lambda/package.json +++ b/packages/@aws-cdk/aws-lambda/package.json @@ -91,6 +91,8 @@ "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-codeguruprofiler": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", + "@aws-cdk/aws-ecr-assets": "0.0.0", "@aws-cdk/aws-efs": "0.0.0", "@aws-cdk/aws-events": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", @@ -108,6 +110,8 @@ "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-codeguruprofiler": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", + "@aws-cdk/aws-ecr-assets": "0.0.0", "@aws-cdk/aws-efs": "0.0.0", "@aws-cdk/aws-events": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", @@ -137,13 +141,6 @@ "docs-public-apis:@aws-cdk/aws-lambda.RuntimeFamily.NODEJS", "docs-public-apis:@aws-cdk/aws-lambda.Alias.lambda", "docs-public-apis:@aws-cdk/aws-lambda.Alias.fromAliasAttributes", - "docs-public-apis:@aws-cdk/aws-lambda.Code", - "docs-public-apis:@aws-cdk/aws-lambda.Code.asset", - "docs-public-apis:@aws-cdk/aws-lambda.Code.bucket", - "docs-public-apis:@aws-cdk/aws-lambda.Code.cfnParameters", - "docs-public-apis:@aws-cdk/aws-lambda.Code.fromBucket", - "docs-public-apis:@aws-cdk/aws-lambda.Code.fromInline", - "docs-public-apis:@aws-cdk/aws-lambda.Code.inline", "docs-public-apis:@aws-cdk/aws-lambda.Function.fromFunctionArn", "docs-public-apis:@aws-cdk/aws-lambda.FunctionBase", "docs-public-apis:@aws-cdk/aws-lambda.QualifiedFunctionBase", @@ -154,9 +151,6 @@ "docs-public-apis:@aws-cdk/aws-lambda.AliasAttributes", "docs-public-apis:@aws-cdk/aws-lambda.AliasAttributes.aliasName", "docs-public-apis:@aws-cdk/aws-lambda.AliasAttributes.aliasVersion", - "docs-public-apis:@aws-cdk/aws-lambda.CodeConfig", - "props-default-doc:@aws-cdk/aws-lambda.CodeConfig.inlineCode", - "props-default-doc:@aws-cdk/aws-lambda.CodeConfig.s3Location", "docs-public-apis:@aws-cdk/aws-lambda.EventSourceMappingOptions", "props-default-doc:@aws-cdk/aws-lambda.FunctionAttributes.role", "props-default-doc:@aws-cdk/aws-lambda.FunctionAttributes.securityGroup", diff --git a/packages/@aws-cdk/aws-lambda/test/code.test.ts b/packages/@aws-cdk/aws-lambda/test/code.test.ts index b00cf74562c3e..a822ba698697e 100644 --- a/packages/@aws-cdk/aws-lambda/test/code.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/code.test.ts @@ -1,6 +1,7 @@ import '@aws-cdk/assert/jest'; import * as path from 'path'; -import { ResourcePart } from '@aws-cdk/assert'; +import { ABSENT, ResourcePart } from '@aws-cdk/assert'; +import * as ecr from '@aws-cdk/aws-ecr'; import * as cdk from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; import * as lambda from '../lib'; @@ -169,6 +170,143 @@ describe('code', () => { expect(overrides['ObjectKeyParam']).toEqual('SomeObjectKey'); }); }); + + describe('lambda.Code.fromEcr', () => { + test('repository uri is correctly identified', () => { + // given + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromEcrImage(repo), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + // then + expect(stack).toHaveResource('AWS::Lambda::Function', { + Code: { + ImageUri: stack.resolve(repo.repositoryUriForTag('latest')), + }, + ImageConfig: ABSENT, + }); + }); + + test('props are correctly resolved', () => { + // given + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromEcrImage(repo, { + cmd: ['cmd', 'param1'], + entrypoint: ['entrypoint', 'param2'], + tag: 'mytag', + }), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + // then + expect(stack).toHaveResource('AWS::Lambda::Function', { + Code: { + ImageUri: stack.resolve(repo.repositoryUriForTag('mytag')), + }, + ImageConfig: { + Command: ['cmd', 'param1'], + EntryPoint: ['entrypoint', 'param2'], + }, + }); + }); + + test('permission grants', () => { + // given + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromEcrImage(repo), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + // then + expect(stack).toHaveResourceLike('AWS::ECR::Repository', { + RepositoryPolicyText: { + Statement: [ + { + Action: [ + 'ecr:BatchCheckLayerAvailability', + 'ecr:GetDownloadUrlForLayer', + 'ecr:BatchGetImage', + ], + Effect: 'Allow', + Principal: { + Service: 'lambda.amazonaws.com', + }, + }, + ], + }, + }); + }); + }); + + describe('lambda.Code.fromImageAsset', () => { + test('repository uri is correctly identified', () => { + // given + const stack = new cdk.Stack(); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromAssetImage(path.join(__dirname, 'docker-lambda-handler')), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + // then + expect(stack).toHaveResource('AWS::Lambda::Function', { + Code: { + ImageUri: { + 'Fn::Join': ['', [ + { Ref: 'AWS::AccountId' }, + '.dkr.ecr.', + { Ref: 'AWS::Region' }, + '.', + { Ref: 'AWS::URLSuffix' }, + '/aws-cdk/assets:0874c7dfd254e95f5181cc7fa643e4abf010f68e5717e373b6e635b49a115b2b', + ]], + }, + }, + ImageConfig: ABSENT, + }); + }); + + test('props are correctly resolved', () => { + // given + const stack = new cdk.Stack(); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromAssetImage(path.join(__dirname, 'docker-lambda-handler'), { + cmd: ['cmd', 'param1'], + entrypoint: ['entrypoint', 'param2'], + }), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + // then + expect(stack).toHaveResource('AWS::Lambda::Function', { + ImageConfig: { + Command: ['cmd', 'param1'], + EntryPoint: ['entrypoint', 'param2'], + }, + }); + }); + }); }); function defineFunction(code: lambda.Code, runtime: lambda.Runtime = lambda.Runtime.NODEJS_10_X) { diff --git a/packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/Dockerfile b/packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/Dockerfile new file mode 100644 index 0000000000000..18064bbe78ba1 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/Dockerfile @@ -0,0 +1,8 @@ +FROM 628053151772.dkr.ecr.sa-east-1.amazonaws.com/awslambda/nodejs12.x-runtime-internal:beta +ARG FUNCTION_DIR="/var/task" +# Create function directory +RUN mkdir -p ${FUNCTION_DIR} +# Copy handler function and package.json +COPY app.js ${FUNCTION_DIR} +# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) +CMD [ "app.handler" ] \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/app.ts b/packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/app.ts new file mode 100644 index 0000000000000..99155b53d5bf7 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/docker-lambda-handler/app.ts @@ -0,0 +1,9 @@ +/* eslint-disable no-console */ + +exports.handler = async (event: any) => { + console.log('hello world'); + console.log(`event ${JSON.stringify(event)}`); + return { + statusCode: 200, + }; +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/function.test.ts b/packages/@aws-cdk/aws-lambda/test/function.test.ts index 038167a460423..3a10ebab4247b 100644 --- a/packages/@aws-cdk/aws-lambda/test/function.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/function.test.ts @@ -759,6 +759,22 @@ describe('function', () => { }, ResourcePart.CompleteDefinition); }); + test('runtime and handler set to FROM_IMAGE are set to undefined in CloudFormation', () => { + const stack = new cdk.Stack(); + + new lambda.Function(stack, 'MyLambda', { + code: lambda.Code.fromAssetImage(path.join(__dirname, 'docker-lambda-handler')), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + expect(stack).toHaveResource('AWS::Lambda::Function', { + Runtime: ABSENT, + Handler: ABSENT, + PackageType: 'Image', + }); + }); + describe('grantInvoke', () => { test('adds iam:InvokeFunction', () => { @@ -1814,6 +1830,130 @@ describe('function', () => { }); }); }); + + describe('code config', () => { + class MyCode extends lambda.Code { + public readonly isInline: boolean; + constructor(private readonly config: lambda.CodeConfig) { + super(); + this.isInline = 'inlineCode' in config; + } + + public bind(_scope: constructs.Construct): lambda.CodeConfig { + return this.config; + } + } + + test('only one of inline, s3 or imageConfig are allowed', () => { + const stack = new cdk.Stack(); + + expect(() => new lambda.Function(stack, 'Fn1', { + code: new MyCode({}), + handler: 'index.handler', + runtime: lambda.Runtime.GO_1_X, + })).toThrow(/lambda.Code must specify exactly one of/); + + expect(() => new lambda.Function(stack, 'Fn2', { + code: new MyCode({ + inlineCode: 'foo', + image: { imageUri: 'bar' }, + }), + handler: 'index.handler', + runtime: lambda.Runtime.GO_1_X, + })).toThrow(/lambda.Code must specify exactly one of/); + + expect(() => new lambda.Function(stack, 'Fn3', { + code: new MyCode({ + image: { imageUri: 'baz' }, + s3Location: { bucketName: 's3foo', objectKey: 's3bar' }, + }), + handler: 'index.handler', + runtime: lambda.Runtime.GO_1_X, + })).toThrow(/lambda.Code must specify exactly one of/); + + expect(() => new lambda.Function(stack, 'Fn4', { + code: new MyCode({ inlineCode: 'baz', s3Location: { bucketName: 's3foo', objectKey: 's3bar' } }), + handler: 'index.handler', + runtime: lambda.Runtime.GO_1_X, + })).toThrow(/lambda.Code must specify exactly one of/); + }); + + test('handler must be FROM_IMAGE when image asset is specified', () => { + const stack = new cdk.Stack(); + + expect(() => new lambda.Function(stack, 'Fn1', { + code: lambda.Code.fromAssetImage('test/docker-lambda-handler'), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + })).not.toThrow(); + + expect(() => new lambda.Function(stack, 'Fn2', { + code: lambda.Code.fromAssetImage('test/docker-lambda-handler'), + handler: 'index.handler', + runtime: lambda.Runtime.FROM_IMAGE, + })).toThrow(/handler must be.*FROM_IMAGE/); + }); + + test('runtime must be FROM_IMAGE when image asset is specified', () => { + const stack = new cdk.Stack(); + + expect(() => new lambda.Function(stack, 'Fn1', { + code: lambda.Code.fromAssetImage('test/docker-lambda-handler'), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + })).not.toThrow(); + + expect(() => new lambda.Function(stack, 'Fn2', { + code: lambda.Code.fromAssetImage('test/docker-lambda-handler'), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.GO_1_X, + })).toThrow(/runtime must be.*FROM_IMAGE/); + }); + + test('imageUri is correctly configured', () => { + const stack = new cdk.Stack(); + + new lambda.Function(stack, 'Fn1', { + code: new MyCode({ + image: { + imageUri: 'ecr image uri', + }, + }), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + expect(stack).toHaveResource('AWS::Lambda::Function', { + Code: { + ImageUri: 'ecr image uri', + }, + ImageConfig: ABSENT, + }); + }); + + test('imageConfig is correctly configured', () => { + const stack = new cdk.Stack(); + + new lambda.Function(stack, 'Fn1', { + code: new MyCode({ + image: { + imageUri: 'ecr image uri', + cmd: ['cmd', 'param1'], + entrypoint: ['entrypoint', 'param2'], + }, + }), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + expect(stack).toHaveResource('AWS::Lambda::Function', { + ImageConfig: { + Command: ['cmd', 'param1'], + EntryPoint: ['entrypoint', 'param2'], + }, + }); + }); + }); }); function newTestLambda(scope: constructs.Construct) { diff --git a/packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.expected.json b/packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.expected.json new file mode 100644 index 0000000000000..bfa1d27910000 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.expected.json @@ -0,0 +1,71 @@ +{ + "Resources": { + "MyLambdaServiceRole4539ECB6": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MyLambdaCCE802FB": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ImageUri": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/aws-cdk/assets:e8a944aeb0a08ba4811503d9c138e514b112dadca84daa5b4608e4a0fb80a0c9" + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "MyLambdaServiceRole4539ECB6", + "Arn" + ] + }, + "PackageType": "Image" + }, + "DependsOn": [ + "MyLambdaServiceRole4539ECB6" + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.ts b/packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.ts new file mode 100644 index 0000000000000..3870d0cadf3b5 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/integ.lambda.docker.ts @@ -0,0 +1,18 @@ +import * as path from 'path'; +import { App, Stack } from '@aws-cdk/core'; +import { DockerImageCode, DockerImageFunction } from '../lib'; + +class TestStack extends Stack { + constructor(scope: App, id: string) { + super(scope, id); + + new DockerImageFunction(this, 'MyLambda', { + code: DockerImageCode.fromImageAsset(path.join(__dirname, 'docker-lambda-handler')), + }); + } +} + +const app = new App(); +new TestStack(app, 'lambda-ecr-docker'); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/cfnspec/spec-source/490_Lambda_Containers_patch.json b/packages/@aws-cdk/cfnspec/spec-source/490_Lambda_Containers_patch.json new file mode 100644 index 0000000000000..cc53bd1139ad5 --- /dev/null +++ b/packages/@aws-cdk/cfnspec/spec-source/490_Lambda_Containers_patch.json @@ -0,0 +1,79 @@ +{ + "PropertyTypes": { + "patch": { + "description": "Properties for Lambda Function Container Image", + "operations": [ + { + "op": "add", + "path": "/AWS::Lambda::Function.ImageConfig", + "value": { + "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-imageconfig.html", + "Properties": { + "Command": { + "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-imageconfig.html#cfn-lambda-function-imageconfig-command", + "DuplicatesAllowed": false, + "PrimitiveItemType": "String", + "Required": false, + "Type": "List", + "UpdateType": "Mutable" + }, + "EntryPoint": { + "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-imageconfig.html#cfn-lambda-function-imageconfig-entrypoint", + "DuplicatesAllowed": false, + "PrimitiveItemType": "String", + "Required": false, + "Type": "List", + "UpdateType": "Mutable" + }, + "WorkingDirectory": { + "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-imageconfig.html#cfn-lambda-function-imageconfig-workingdirectory", + "PrimitiveType": "String", + "Required": false, + "UpdateType": "Mutable" + } + } + } + }, + { + "op": "add", + "path": "/AWS::Lambda::Function.Code/Properties/ImageUri", + "value": { + "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html#cfn-lambda-function-code-imageuri", + "PrimitiveType": "String", + "Required": false, + "UpdateType": "Mutable" + } + } + ] + } + }, + "ResourceTypes": { + "AWS::Lambda::Function": { + "patch": { + "description": "updates to Lambda Function to support Container Image", + "operations": [ + { + "op": "add", + "path": "/Properties/ImageConfig", + "value": { + "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-function.html#cfn-lambda-function-imageconfig", + "Required": false, + "Type": "ImageConfig", + "UpdateType": "Mutable" + } + }, + { + "op": "add", + "path": "/Properties/PackageType", + "value": { + "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-function.html#cfn-lambda-function-packagetype", + "PrimitiveType": "String", + "Required": false, + "UpdateType": "Mutable" + } + } + ] + } + } + } +} \ No newline at end of file