Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(assets): enable local tooling scenarios such as lambda debugging #1433

Merged
merged 7 commits into from
Dec 27, 2018
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions design/code-asset-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# RFC: AWS Lambda - Metadata about Code Assets

As described in [#1432](https://github.com/awslabs/aws-cdk/issues/1432), in order to support local debugging,
debuggers like [SAM CLI](https://github.com/awslabs/aws-sam-cli) need to be able to find the code of a Lambda
function locally.

The current implementation of assets uses CloudFormation Parameters which represent the S3 bucket and key of the
uploaded asset, which makes it impossible for local tools to reason about (without traversing the cx protocol with
many heuristics).

## Approach

We will automatically embed CloudFormation metadata on `AWS::Lambda::Function` resources which use
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why only AWS::Lambda::Functions? What about AWS::Lambda::LayerVersion, AWS::Serverless::Function, and AWS::Serverless::LayerVersion?

We are purposefully excluding API Gateway from this correct?

Is there a generic way this could work to allow any resource using an Asset to get this metadata? Trying to think broader here to make less work in the future of manually adding this metadata to every resource.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation allows anyone who uses an asset to attach this metadata to the resource by calling asset.addResourceMetadata(resource, prop). I decided against doing this automatically because this problem is basically confined to only L2 constructs. Any higher level constructs shouldn't care about this at all, so I opted for a more manual solution.

Layers are currently in PR (#1411) and @RomainMuller should probably.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eladb What about AWS::Serverless::Function?

Copy link
Contributor Author

@eladb eladb Dec 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jfuss the AWS::Serverless::Function is currently only supported as an L1 construct (serverless.CfnFunction), and I suspect people who use it will likely not try to use it with CDK assets but rather with CodeUri. For example:

new serverless.CfnFunction(this, 'Func', {
  codeUri: 'file://mycode',
   runtime: 'nodejs8.10',
   handler: 'index.handler'
});

If users wish to use CDK assets (and invoke them locally through SAM CLI), this is what they will have to do:

const asset = new assets.ZipDirectoryAsset(this, 'Foo', {
  path: '/foo/boom'
});

const resource = new serverless.CfnFunction(this, 'Func', {
    codeUri: {
    bucket: asset.s3BucketName,
    key: asset.s3ObjectKey
  },
  runtime: 'nodejs8.10',
  handler: 'index.handler'
});

resource.addResourceMetadata(resource, 'CodeUri');

But TBH, I doubt that this makes sense. If you are already writing "CDK native" code, the L2 constructs for Lambda, API Gateway, etc provides a much richer API then the AWS::Serverless::Function resource.

What do you think?

Copy link
Contributor

@jfuss jfuss Dec 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eladb I see. So it's more like Assets aren't directly supported within AWS::Serverless::Function like it is with AWS::Lambda::Functions (over simplifying here due to the Construct level difference). I see Assets being powerful and something we should consider supporting directly but out of scope for this PR.

Thanks for clarifying this.

local assets for code. The metadata will allow tools like SAM CLI to find the code locally for local invocations.

## Design

Given a CDK app with an AWS Lambda function defined like so:

```ts
new lambda.Function(this, 'MyHandler', {
// ...
code: lambda.Code.asset('/path/to/handler')
});
```

The synthesized `AWS::Lambda::Function` resource will include a "Metadata" entry as follows:

```js
{
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
// current asset magic
}
},
"Metadata": {
"aws:asset:property": "Code",
"aws:asset:path": "/path/to/handler"
}
}
```

Local debugging tools like SAM CLI will be able to traverse the template and look up the `aws:asset` metadata
entries, and use them to process the template so it will be compatible with their inputs.

22 changes: 22 additions & 0 deletions packages/@aws-cdk/assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,25 @@ the asset store, it is uploaded during deployment.

Now, when the toolkit deploys the stack, it will set the relevant CloudFormation
Parameters to point to the actual bucket and key for each asset.

## CloudFormation Resource Metadata

> NOTE: This section is relevant for authors of AWS Resource Constructs.

In certain situations, it is desirable for tools to be able to know that a certain CloudFormation
resource is using a local asset. For example, SAM CLI can be used to invoke AWS Lambda functions
locally for debugging purposes.

To enable such use cases, external tools will consult a set of metadata entries on AWS CloudFormation
resources:

- `aws:asset:path` points to the local path of the asset.
- `aws:asset:property` is the name of the resource property where the asset is used

Using these two metadata entries, tools will be able to identify that assets are used
by a certain resource, and enable advanced local experiences.

To add these metadata entries to a resource, use the
`asset.addResourceMetadata(resource, property)` method.

See https://github.com/awslabs/aws-cdk/issues/1432 for more details
28 changes: 28 additions & 0 deletions packages/@aws-cdk/assets/lib/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,34 @@ export class Asset extends cdk.Construct {
}
}

/**
* 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/awslabs/aws-cdk/issues/1432
*
* @param resource The CloudFormation resource which is using this asset.
* @param resourceProperty The property name where this asset is referenced
* (e.g. "Code" for AWS::Lambda::Function)
*/
public addResourceMetadata(resource: cdk.Resource, resourceProperty: string) {
if (!this.getContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT)) {
return; // not enabled
}

// tell tools such as SAM CLI that the "Code" property of this resource
// points to a local path in order to enable local invocation of this function.
resource.options.metadata = resource.options.metadata || { };
resource.options.metadata[cxapi.ASSET_RESOURCE_METADATA_PATH_KEY] = this.assetPath;
resource.options.metadata[cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY] = resourceProperty;
}

/**
* Grants read permissions to the principal on the asset's S3 object.
*/
Expand Down
47 changes: 46 additions & 1 deletion packages/@aws-cdk/assets/test/test.asset.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, haveResource } from '@aws-cdk/assert';
import { expect, haveResource, ResourcePart } from '@aws-cdk/assert';
import iam = require('@aws-cdk/aws-iam');
import cdk = require('@aws-cdk/cdk');
import cxapi = require('@aws-cdk/cx-api');
import { Test } from 'nodeunit';
import path = require('path');
import { FileAsset, ZipDirectoryAsset } from '../lib/asset';
Expand Down Expand Up @@ -139,6 +140,50 @@ export = {
test.equal(zipDirectoryAsset.isZipArchive, true);
test.equal(zipFileAsset.isZipArchive, true);
test.equal(jarFileAsset.isZipArchive, true);
test.done();
},

'addResourceMetadata can be used to add CFN metadata to resources'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
stack.setContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT, true);

const location = path.join(__dirname, 'sample-asset-directory');
const resource = new cdk.Resource(stack, 'MyResource', { type: 'My::Resource::Type' });
const asset = new ZipDirectoryAsset(stack, 'MyAsset', { path: location });

// WHEN
asset.addResourceMetadata(resource, 'PropName');

// THEN
expect(stack).to(haveResource('My::Resource::Type', {
Metadata: {
"aws:asset:path": location,
"aws:asset:property": "PropName"
}
}, ResourcePart.CompleteDefinition));
test.done();
},

'asset metadata is only emitted if ASSET_RESOURCE_METADATA_ENABLED_CONTEXT is defined'(test: Test) {
// GIVEN
const stack = new cdk.Stack();

const location = path.join(__dirname, 'sample-asset-directory');
const resource = new cdk.Resource(stack, 'MyResource', { type: 'My::Resource::Type' });
const asset = new ZipDirectoryAsset(stack, 'MyAsset', { path: location });

// WHEN
asset.addResourceMetadata(resource, 'PropName');

// THEN
expect(stack).notTo(haveResource('My::Resource::Type', {
Metadata: {
"aws:asset:path": location,
"aws:asset:property": "PropName"
}
}, ResourcePart.CompleteDefinition));

test.done();
}
};
11 changes: 7 additions & 4 deletions packages/@aws-cdk/aws-lambda/lib/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export abstract class Code {
* Called during stack synthesis to render the CodePropery for the
* Lambda function.
*/
public abstract toJSON(): CfnFunction.CodeProperty;
public abstract toJSON(resource: CfnFunction): CfnFunction.CodeProperty;

/**
* Called when the lambda is initialized to allow this object to
Expand All @@ -81,7 +81,7 @@ export class S3Code extends Code {
this.bucketName = bucket.bucketName;
}

public toJSON(): CfnFunction.CodeProperty {
public toJSON(_: CfnFunction): CfnFunction.CodeProperty {
return {
s3Bucket: this.bucketName,
s3Key: this.key,
Expand All @@ -108,7 +108,7 @@ export class InlineCode extends Code {
}
}

public toJSON(): CfnFunction.CodeProperty {
public toJSON(_: CfnFunction): CfnFunction.CodeProperty {
return {
zipFile: this.code
};
Expand Down Expand Up @@ -156,7 +156,10 @@ export class AssetCode extends Code {
}
}

public toJSON(): CfnFunction.CodeProperty {
public toJSON(resource: CfnFunction): CfnFunction.CodeProperty {
// https://github.com/awslabs/aws-cdk/issues/1432
this.asset!.addResourceMetadata(resource, 'Code');

return {
s3Bucket: this.asset!.s3BucketName,
s3Key: this.asset!.s3ObjectKey
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-lambda/lib/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export class Function extends FunctionRef {
const resource = new CfnFunction(this, 'Resource', {
functionName: props.functionName,
description: props.description,
code: new cdk.Token(() => props.code.toJSON()),
code: new cdk.Token(() => props.code.toJSON(resource)),
handler: props.handler,
timeout: props.timeout,
runtime: props.runtime.name,
Expand Down
26 changes: 26 additions & 0 deletions packages/@aws-cdk/aws-lambda/test/test.code.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, haveResource, ResourcePart } from '@aws-cdk/assert';
import assets = require('@aws-cdk/assets');
import cdk = require('@aws-cdk/cdk');
import cxapi = require('@aws-cdk/cx-api');
import { Test } from 'nodeunit';
import path = require('path');
import lambda = require('../lib');
Expand Down Expand Up @@ -65,6 +67,30 @@ export = {
test.deepEqual(synthesized.metadata['/MyStack/Func1/Code'][0].type, 'aws:cdk:asset');
test.deepEqual(synthesized.metadata['/MyStack/Func2/Code'], undefined);

test.done();
},

'adds code asset metadata'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
stack.setContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT, true);

const location = path.join(__dirname, 'my-lambda-handler');

// WHEN
new lambda.Function(stack, 'Func1', {
code: lambda.Code.asset(location),
runtime: lambda.Runtime.NodeJS810,
handler: 'foom',
});

// THEN
expect(stack).to(haveResource('AWS::Lambda::Function', {
Metadata: {
[cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: location,
[cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY]: 'Code'
}
}, ResourcePart.CompleteDefinition));
test.done();
}
}
Expand Down
12 changes: 0 additions & 12 deletions packages/@aws-cdk/cx-api/lib/cxapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,3 @@ export const PATH_METADATA_KEY = 'aws:cdk:path';
* Enables the embedding of the "aws:cdk:path" in CloudFormation template metadata.
*/
export const PATH_METADATA_ENABLE_CONTEXT = 'aws:cdk:enable-path-metadata';

/**
* Separator string that separates the prefix separator from the object key separator.
*
* Asset keys will look like:
*
* /assets/MyConstruct12345678/||abcdef12345.zip
*
* This allows us to encode both the prefix and the full location in a single
* CloudFormation Template Parameter.
*/
export const ASSET_PREFIX_SEPARATOR = '||';
26 changes: 26 additions & 0 deletions packages/@aws-cdk/cx-api/lib/metadata/assets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
export const ASSET_METADATA = 'aws:cdk:asset';

/**
* If this is set in the context, the aws:asset:xxx metadata entries will not be
* added to the template. This is used, for example, when we run integrationt
* tests.
*/
export const ASSET_RESOURCE_METADATA_ENABLED_CONTEXT = 'aws:cdk:enable-asset-metadata';

/**
* Metadata added to the CloudFormation template entries that map local assets
* to resources.
*/
export const ASSET_RESOURCE_METADATA_PATH_KEY = 'aws:asset:path';
export const ASSET_RESOURCE_METADATA_PROPERTY_KEY = 'aws:asset:property';

/**
* Separator string that separates the prefix separator from the object key separator.
*
* Asset keys will look like:
*
* /assets/MyConstruct12345678/||abcdef12345.zip
*
* This allows us to encode both the prefix and the full location in a single
* CloudFormation Template Parameter.
*/
export const ASSET_PREFIX_SEPARATOR = '||';

export interface FileAssetMetadataEntry {
/**
* Requested packaging style
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async function parseCommandLineArguments() {
.option('ec2creds', { type: 'boolean', alias: 'i', default: undefined, desc: 'Force trying to fetch EC2 instance credentials. Default: guess EC2 instance status.' })
.option('version-reporting', { type: 'boolean', desc: 'Include the "AWS::CDK::Metadata" resource in synthesized templates (enabled by default)', default: undefined })
.option('path-metadata', { type: 'boolean', desc: 'Include "aws:cdk:path" CloudFormation metadata for each resource (enabled by default)', default: true })
.option('asset-metadata', { type: 'boolean', desc: 'Include "aws:asset:*" CloudFormation metadata for resources that user assets (enabled by default)', default: true })
.option('role-arn', { type: 'string', alias: 'r', desc: 'ARN of Role to use when invoking CloudFormation', default: undefined })
.command([ 'list', 'ls' ], 'Lists all stacks in the app', yargs => yargs
.option('long', { type: 'boolean', default: false, alias: 'l', desc: 'display environment information for each stack' }))
Expand Down
11 changes: 10 additions & 1 deletion packages/aws-cdk/lib/api/cxapp/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,22 @@ export async function execProgram(aws: SDK, config: Settings): Promise<cxapi.Syn

let pathMetadata: boolean = config.get(['pathMetadata']);
if (pathMetadata === undefined) {
pathMetadata = true; // default to true
pathMetadata = true; // defaults to true
}

if (pathMetadata) {
context[cxapi.PATH_METADATA_ENABLE_CONTEXT] = true;
}

let assetMetadata: boolean = config.get(['assetMetadata']);
if (assetMetadata === undefined) {
assetMetadata = true; // defaults to true
}

if (assetMetadata) {
context[cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT] = true;
}

debug('context:', context);

env[cxapi.CONTEXT_ENV] = JSON.stringify(context);
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class Settings {
context,
language: argv.language,
pathMetadata: argv.pathMetadata,
assetMetadata: argv.assetMetadata,
plugin: argv.plugin,
requireApproval: argv.requireApproval,
toolkitStackName: argv.toolkitStackName,
Expand Down
2 changes: 1 addition & 1 deletion tools/cdk-integ-tools/bin/cdk-integ-assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async function main() {
}

const expected = await test.readExpected();
const actual = await test.invoke(['--json', '--no-path-metadata', 'synth'], { json: true, context: STATIC_TEST_CONTEXT });
const actual = await test.invoke(['--json', '--no-path-metadata', '--no-asset-metadata', 'synth'], { json: true, context: STATIC_TEST_CONTEXT });

const diff = diffTemplate(expected, actual);

Expand Down
3 changes: 2 additions & 1 deletion tools/cdk-integ-tools/bin/cdk-integ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ async function main() {

const args = new Array<string>();

// inject "--no-path-metadata" so aws:cdk:path entries are not added to CFN metadata
// don't inject cloudformation metadata into template
args.push('--no-path-metadata');
args.push('--no-asset-metadata');

// inject "--verbose" to the command line of "cdk" if we are in verbose mode
if (argv.verbose) {
Expand Down