From f52d9bf13d2bb3c066ba227259a2d98a5947982b Mon Sep 17 00:00:00 2001 From: Ruperto Torres <86501267+torresxb1@users.noreply.github.com> Date: Mon, 15 Nov 2021 10:04:42 -0800 Subject: [PATCH] fix(lambda): SAM CLI asset metadata missing from image Functions (#17368) Adds asset metadata to image-type lambda functions. This will allow SAM CLI to support local invocation of image-type lambdas from CDK-synthed templates. It follows the same design and builds upon https://github.com/aws/aws-cdk/pull/1433 Fixes https://github.com/aws/aws-cdk/issues/14593 Uses some changes from https://github.com/aws/aws-cdk/pull/17293 to enable asset metadata generation in integration tests *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../aws-ecr-assets/lib/image-asset.ts | 71 +++++++++++- packages/@aws-cdk/aws-lambda/lib/code.ts | 31 ++++-- .../@aws-cdk/aws-lambda/test/code.test.ts | 104 ++++++++++++++++++ packages/@aws-cdk/cx-api/lib/assets.ts | 3 + 4 files changed, 197 insertions(+), 12 deletions(-) diff --git a/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts b/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts index e579baf55ac41..78e706587dd16 100644 --- a/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts +++ b/packages/@aws-cdk/aws-ecr-assets/lib/image-asset.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as ecr from '@aws-cdk/aws-ecr'; -import { Annotations, AssetStaging, FeatureFlags, FileFingerprintOptions, IgnoreMode, Stack, SymlinkFollowMode, Token, Stage } from '@aws-cdk/core'; +import { Annotations, AssetStaging, FeatureFlags, FileFingerprintOptions, IgnoreMode, Stack, SymlinkFollowMode, Token, Stage, CfnResource } from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; @@ -147,6 +147,30 @@ export class DockerImageAsset extends CoreConstruct implements IAsset { */ public readonly assetHash: string; + /** + * The path to the asset, relative to the current Cloud Assembly + * + * If asset staging is disabled, this will just be the original path. + * + * If asset staging is enabled it will be the staged path. + */ + private readonly assetPath: string; + + /** + * The path to the Dockerfile, relative to the assetPath + */ + private readonly dockerfilePath?: string; + + /** + * Build args to pass to the `docker build` command. + */ + private readonly dockerBuildArgs?: { [key: string]: string }; + + /** + * Docker target to build to + */ + private readonly dockerBuildTarget?: string; + constructor(scope: Construct, id: string, props: DockerImageAssetProps) { super(scope, id); @@ -160,7 +184,8 @@ export class DockerImageAsset extends CoreConstruct implements IAsset { } // validate the docker file exists - const file = path.join(dir, props.file || 'Dockerfile'); + this.dockerfilePath = props.file || 'Dockerfile'; + const file = path.join(dir, this.dockerfilePath); if (!fs.existsSync(file)) { throw new Error(`Cannot find file at ${file}`); } @@ -223,10 +248,14 @@ export class DockerImageAsset extends CoreConstruct implements IAsset { this.assetHash = staging.assetHash; const stack = Stack.of(this); + this.assetPath = staging.relativeStagedPath(stack); + this.dockerBuildArgs = props.buildArgs; + this.dockerBuildTarget = props.target; + const location = stack.synthesizer.addDockerImageAsset({ - directoryName: staging.relativeStagedPath(stack), - dockerBuildArgs: props.buildArgs, - dockerBuildTarget: props.target, + directoryName: this.assetPath, + dockerBuildArgs: this.dockerBuildArgs, + dockerBuildTarget: this.dockerBuildTarget, dockerFile: props.file, sourceHash: staging.assetHash, }); @@ -234,6 +263,38 @@ export class DockerImageAsset extends CoreConstruct implements IAsset { this.repository = ecr.Repository.fromRepositoryName(this, 'Repository', location.repositoryName); this.imageUri = location.imageUri; } + + /** + * Adds CloudFormation template metadata to the specified resource with + * information that indicates which resource property is mapped to this local + * asset. This can be used by tools such as SAM CLI to provide local + * experience such as local invocation and debugging of Lambda functions. + * + * Asset metadata will only be included if the stack is synthesized with the + * "aws:cdk:enable-asset-metadata" context key defined, which is the default + * behavior when synthesizing via the CDK Toolkit. + * + * @see https://github.com/aws/aws-cdk/issues/1432 + * + * @param resource The CloudFormation resource which is using this asset [disable-awslint:ref-via-interface] + * @param resourceProperty The property name where this asset is referenced + */ + public addResourceMetadata(resource: CfnResource, resourceProperty: string) { + if (!this.node.tryGetContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT)) { + return; // not enabled + } + + // tell tools such as SAM CLI that the resourceProperty of this resource + // points to a local path and include the path to de dockerfile, docker build args, and target, + // in order to enable local invocation of this function. + resource.cfnOptions.metadata = resource.cfnOptions.metadata || { }; + resource.cfnOptions.metadata[cxapi.ASSET_RESOURCE_METADATA_PATH_KEY] = this.assetPath; + resource.cfnOptions.metadata[cxapi.ASSET_RESOURCE_METADATA_DOCKERFILE_PATH_KEY] = this.dockerfilePath; + resource.cfnOptions.metadata[cxapi.ASSET_RESOURCE_METADATA_DOCKER_BUILD_ARGS_KEY] = this.dockerBuildArgs; + resource.cfnOptions.metadata[cxapi.ASSET_RESOURCE_METADATA_DOCKER_BUILD_TARGET_KEY] = this.dockerBuildTarget; + resource.cfnOptions.metadata[cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY] = resourceProperty; + } + } function validateProps(props: DockerImageAssetProps) { diff --git a/packages/@aws-cdk/aws-lambda/lib/code.ts b/packages/@aws-cdk/aws-lambda/lib/code.ts index 293c91f1485d9..f51e91de9bfb7 100644 --- a/packages/@aws-cdk/aws-lambda/lib/code.ts +++ b/packages/@aws-cdk/aws-lambda/lib/code.ts @@ -517,28 +517,45 @@ export interface AssetImageCodeProps extends ecr_assets.DockerImageAssetOptions */ export class AssetImageCode extends Code { public readonly isInline: boolean = false; + private asset?: ecr_assets.DockerImageAsset; constructor(private readonly directory: string, private readonly props: AssetImageCodeProps) { super(); } public bind(scope: Construct): CodeConfig { - const asset = new ecr_assets.DockerImageAsset(scope, 'AssetImage', { - directory: this.directory, - ...this.props, - }); - - asset.repository.grantPull(new iam.ServicePrincipal('lambda.amazonaws.com')); + // If the same AssetImageCode is used multiple times, retain only the first instantiation. + if (!this.asset) { + this.asset = new ecr_assets.DockerImageAsset(scope, 'AssetImage', { + directory: this.directory, + ...this.props, + }); + this.asset.repository.grantPull(new iam.ServicePrincipal('lambda.amazonaws.com')); + } else if (cdk.Stack.of(this.asset) !== cdk.Stack.of(scope)) { + throw new Error(`Asset is already associated with another stack '${cdk.Stack.of(this.asset).stackName}'. ` + + 'Create a new Code instance for every stack.'); + } return { image: { - imageUri: asset.imageUri, + imageUri: this.asset.imageUri, entrypoint: this.props.entrypoint, cmd: this.props.cmd, workingDirectory: this.props.workingDirectory, }, }; } + + public bindToResource(resource: cdk.CfnResource, options: ResourceBindOptions = { }) { + if (!this.asset) { + throw new Error('bindToResource() must be called after bind()'); + } + + const resourceProperty = options.resourceProperty || 'Code.ImageUri'; + + // https://github.com/aws/aws-cdk/issues/14593 + this.asset.addResourceMetadata(resource, resourceProperty); + } } /** diff --git a/packages/@aws-cdk/aws-lambda/test/code.test.ts b/packages/@aws-cdk/aws-lambda/test/code.test.ts index 194ebda9aff37..9b67102d8e8c8 100644 --- a/packages/@aws-cdk/aws-lambda/test/code.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/code.test.ts @@ -332,6 +332,110 @@ describe('code', () => { }, }); }); + + test('only one Asset object gets created even if multiple functions use the same AssetImageCode', () => { + // given + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'MyStack'); + const directoryAsset = lambda.Code.fromAssetImage(path.join(__dirname, 'docker-lambda-handler')); + + // when + new lambda.Function(stack, 'Fn1', { + code: directoryAsset, + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + new lambda.Function(stack, 'Fn2', { + code: directoryAsset, + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + // then + const assembly = app.synth(); + const synthesized = assembly.stacks[0]; + + // Func1 has an asset, Func2 does not + expect(synthesized.assets.length).toEqual(1); + }); + + test('adds code asset metadata', () => { + // given + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT, true); + + const dockerfilePath = 'Dockerfile'; + const dockerBuildTarget = 'stage'; + const dockerBuildArgs = { arg1: 'val1', arg2: 'val2' }; + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromAssetImage(path.join(__dirname, 'docker-lambda-handler'), { + file: dockerfilePath, + target: dockerBuildTarget, + buildArgs: dockerBuildArgs, + }), + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + // then + expect(stack).toHaveResource('AWS::Lambda::Function', { + Metadata: { + [cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: 'asset.650a009a909c30e767a843a84ff7812616447251d245e0ab65d9bfb37f413e32', + [cxapi.ASSET_RESOURCE_METADATA_DOCKERFILE_PATH_KEY]: dockerfilePath, + [cxapi.ASSET_RESOURCE_METADATA_DOCKER_BUILD_ARGS_KEY]: dockerBuildArgs, + [cxapi.ASSET_RESOURCE_METADATA_DOCKER_BUILD_TARGET_KEY]: dockerBuildTarget, + [cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY]: 'Code.ImageUri', + }, + }, ResourcePart.CompleteDefinition); + }); + + test('adds code asset metadata with default dockerfile path', () => { + // given + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT, true); + + // 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', { + Metadata: { + [cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: 'asset.a3cc4528c34874616814d9b3436ff0e5d01514c1d563ed8899657ca00982f308', + [cxapi.ASSET_RESOURCE_METADATA_DOCKERFILE_PATH_KEY]: 'Dockerfile', + [cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY]: 'Code.ImageUri', + }, + }, ResourcePart.CompleteDefinition); + }); + + test('fails if asset is bound with a second stack', () => { + // given + const app = new cdk.App(); + const asset = lambda.Code.fromAssetImage(path.join(__dirname, 'docker-lambda-handler')); + + // when + const stack1 = new cdk.Stack(app, 'Stack1'); + new lambda.Function(stack1, 'Fn', { + code: asset, + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + }); + + const stack2 = new cdk.Stack(app, 'Stack2'); + + // then + expect(() => new lambda.Function(stack2, 'Fn', { + code: asset, + handler: lambda.Handler.FROM_IMAGE, + runtime: lambda.Runtime.FROM_IMAGE, + })).toThrow(/already associated/); + }); }); describe('lambda.Code.fromDockerBuild', () => { diff --git a/packages/@aws-cdk/cx-api/lib/assets.ts b/packages/@aws-cdk/cx-api/lib/assets.ts index c15dbcd82776f..0b3eaa52cefb5 100644 --- a/packages/@aws-cdk/cx-api/lib/assets.ts +++ b/packages/@aws-cdk/cx-api/lib/assets.ts @@ -10,6 +10,9 @@ export const ASSET_RESOURCE_METADATA_ENABLED_CONTEXT = 'aws:cdk:enable-asset-met * to resources. */ export const ASSET_RESOURCE_METADATA_PATH_KEY = 'aws:asset:path'; +export const ASSET_RESOURCE_METADATA_DOCKERFILE_PATH_KEY = 'aws:asset:dockerfile-path'; +export const ASSET_RESOURCE_METADATA_DOCKER_BUILD_ARGS_KEY = 'aws:asset:docker-build-args'; +export const ASSET_RESOURCE_METADATA_DOCKER_BUILD_TARGET_KEY = 'aws:asset:docker-build-target'; export const ASSET_RESOURCE_METADATA_PROPERTY_KEY = 'aws:asset:property'; /**