Skip to content

Commit

Permalink
feat(aws-lambda): support bucket notifications (#561)
Browse files Browse the repository at this point in the history
Allow Lambda functions to be used as destinations for S3 bucket
notifications.
  • Loading branch information
Elad Ben-Israel authored Aug 14, 2018
1 parent 030a7e7 commit 0cbb247
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 5 deletions.
31 changes: 27 additions & 4 deletions packages/@aws-cdk/aws-lambda/lib/lambda-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import cloudwatch = require('@aws-cdk/aws-cloudwatch');
import events = require('@aws-cdk/aws-events');
import iam = require('@aws-cdk/aws-iam');
import logs = require('@aws-cdk/aws-logs');
import s3n = require('@aws-cdk/aws-s3-notifications');
import cdk = require('@aws-cdk/cdk');
import { cloudformation, FunctionArn } from './lambda.generated';
import { Permission } from './permission';
Expand All @@ -23,7 +24,9 @@ export interface FunctionRefProps {
role?: iam.Role;
}

export abstract class FunctionRef extends cdk.Construct implements events.IEventRuleTarget, logs.ILogSubscriptionDestination {
export abstract class FunctionRef extends cdk.Construct
implements events.IEventRuleTarget, logs.ILogSubscriptionDestination, s3n.IBucketNotificationDestination {

/**
* Creates a Lambda function object which represents a function not defined
* within this stack.
Expand Down Expand Up @@ -138,9 +141,9 @@ export abstract class FunctionRef extends cdk.Construct implements events.IEvent

/**
* Adds a permission to the Lambda resource policy.
* @param name A name for the permission construct
* @param id The id ƒor the permission construct
*/
public addPermission(name: string, permission: Permission) {
public addPermission(id: string, permission: Permission) {
if (!this.canCreatePermissions) {
// FIXME: Report metadata
return;
Expand All @@ -149,7 +152,7 @@ export abstract class FunctionRef extends cdk.Construct implements events.IEvent
const principal = this.parsePermissionPrincipal(permission.principal);
const action = permission.action || 'lambda:InvokeFunction';

new cloudformation.PermissionResource(this, name, {
new cloudformation.PermissionResource(this, id, {
action,
principal,
functionName: this.functionName,
Expand Down Expand Up @@ -261,6 +264,26 @@ export abstract class FunctionRef extends cdk.Construct implements events.IEvent
};
}

/**
* Allows this Lambda to be used as a destination for bucket notifications.
* Use `bucket.onEvent(lambda)` to subscribe.
*/
public asBucketNotificationDestination(bucketArn: cdk.Arn, bucketId: string): s3n.BucketNotificationDestinationProps {
const permissionId = `AllowBucketNotificationsFrom${bucketId}`;
if (!this.tryFindChild(permissionId)) {
this.addPermission(permissionId, {
sourceAccount: new cdk.AwsAccountId(),
principal: new cdk.ServicePrincipal('s3.amazonaws.com'),
sourceArn: bucketArn,
});
}

return {
type: s3n.BucketNotificationDestinationType.Lambda,
arn: this.functionArn
};
}

private parsePermissionPrincipal(principal?: cdk.PolicyPrincipal) {
if (!principal) {
return undefined;
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-lambda/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@aws-cdk/aws-iam": "^0.8.1",
"@aws-cdk/aws-logs": "^0.8.1",
"@aws-cdk/aws-s3": "^0.8.1",
"@aws-cdk/aws-s3-notifications": "^0.8.1",
"@aws-cdk/cdk": "^0.8.1",
"@aws-cdk/cx-api": "^0.8.1"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
{
"Resources": {
"MyBucketF68F3FF0": {
"Type": "AWS::S3::Bucket"
},
"MyBucketNotifications46AC0CD2": {
"Type": "Custom::S3BucketNotifications",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"BucketNotificationsHandler050a0587b7544547bf325f094a3db8347ECC3691",
"Arn"
]
},
"BucketName": {
"Ref": "MyBucketF68F3FF0"
},
"NotificationConfiguration": {
"LambdaFunctionConfigurations": [
{
"Events": [
"s3:ObjectCreated:*"
],
"Filter": {
"Key": {
"FilterRules": [
{
"Name": "suffix",
"Value": ".png"
}
]
}
},
"LambdaFunctionArn": {
"Fn::GetAtt": [
"MyFunction3BAA72D1",
"Arn"
]
}
}
]
}
}
},
"MyFunctionServiceRole3C357FF2": {
"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"
]
]
}
]
}
},
"MyFunction3BAA72D1": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"ZipFile": "exports.handler = function handler(event, _context, callback) {\n console.log(JSON.stringify(event, undefined, 2));\n return callback(null, event);\n}"
},
"Handler": "index.handler",
"Role": {
"Fn::GetAtt": [
"MyFunctionServiceRole3C357FF2",
"Arn"
]
},
"Runtime": "nodejs6.10"
},
"DependsOn": [
"MyFunctionServiceRole3C357FF2"
]
},
"MyFunctionAllowBucketNotificationsFromlambdabucketnotificationsMyBucket0F0FC402189522F6": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Ref": "MyFunction3BAA72D1"
},
"Principal": "s3.amazonaws.com",
"SourceAccount": {
"Ref": "AWS::AccountId"
},
"SourceArn": {
"Fn::GetAtt": [
"MyBucketF68F3FF0",
"Arn"
]
}
}
},
"MyFunctionAllowBucketNotificationsFromlambdabucketnotificationsYourBucket307F72F245F2C5AE": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Ref": "MyFunction3BAA72D1"
},
"Principal": "s3.amazonaws.com",
"SourceAccount": {
"Ref": "AWS::AccountId"
},
"SourceArn": {
"Fn::GetAtt": [
"YourBucketC6A57364",
"Arn"
]
}
}
},
"YourBucketC6A57364": {
"Type": "AWS::S3::Bucket"
},
"YourBucketNotifications8D39901A": {
"Type": "Custom::S3BucketNotifications",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"BucketNotificationsHandler050a0587b7544547bf325f094a3db8347ECC3691",
"Arn"
]
},
"BucketName": {
"Ref": "YourBucketC6A57364"
},
"NotificationConfiguration": {
"LambdaFunctionConfigurations": [
{
"Events": [
"s3:ObjectRemoved:*"
],
"LambdaFunctionArn": {
"Fn::GetAtt": [
"MyFunction3BAA72D1",
"Arn"
]
}
}
]
}
}
},
"BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC": {
"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"
]
]
}
]
}
},
"BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleDefaultPolicy2CF63D36": {
"Type": "AWS::IAM::Policy",
"Properties": {
"PolicyDocument": {
"Statement": [
{
"Action": "s3:PutBucketNotification",
"Effect": "Allow",
"Resource": "*"
}
],
"Version": "2012-10-17"
},
"PolicyName": "BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleDefaultPolicy2CF63D36",
"Roles": [
{
"Ref": "BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC"
}
]
}
},
"BucketNotificationsHandler050a0587b7544547bf325f094a3db8347ECC3691": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Description": "AWS CloudFormation handler for \"Custom::S3BucketNotifications\" resources (@aws-cdk/aws-s3)",
"Code": {
"ZipFile": "exports.handler = (event, context) => {\n const s3 = new (require('aws-sdk').S3)();\n const https = require(\"https\");\n const url = require(\"url\");\n log(JSON.stringify(event, undefined, 2));\n const props = event.ResourceProperties;\n if (event.RequestType === 'Delete') {\n props.NotificationConfiguration = {}; // this is how you clean out notifications\n }\n const req = {\n Bucket: props.BucketName,\n NotificationConfiguration: props.NotificationConfiguration\n };\n return s3.putBucketNotificationConfiguration(req, (err, data) => {\n log({ err, data });\n if (err) {\n return submitResponse(\"FAILED\", err.message + `\\nMore information in CloudWatch Log Stream: ${context.logStreamName}`);\n }\n else {\n return submitResponse(\"SUCCESS\");\n }\n });\n function log(obj) {\n console.error(event.RequestId, event.StackId, event.LogicalResourceId, obj);\n }\n // tslint:disable-next-line:max-line-length\n // adapted from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html#cfn-lambda-function-code-cfnresponsemodule\n // to allow sending an error messge as a reason.\n function submitResponse(responseStatus, reason) {\n const responseBody = JSON.stringify({\n Status: responseStatus,\n Reason: reason || \"See the details in CloudWatch Log Stream: \" + context.logStreamName,\n PhysicalResourceId: context.logStreamName,\n StackId: event.StackId,\n RequestId: event.RequestId,\n LogicalResourceId: event.LogicalResourceId,\n NoEcho: false,\n });\n log({ responseBody });\n const parsedUrl = url.parse(event.ResponseURL);\n const options = {\n hostname: parsedUrl.hostname,\n port: 443,\n path: parsedUrl.path,\n method: \"PUT\",\n headers: {\n \"content-type\": \"\",\n \"content-length\": responseBody.length\n }\n };\n const request = https.request(options, (r) => {\n log({ statusCode: r.statusCode, statusMessage: r.statusMessage });\n context.done();\n });\n request.on(\"error\", (error) => {\n log({ sendError: error });\n context.done();\n });\n request.write(responseBody);\n request.end();\n }\n};"
},
"Handler": "index.handler",
"Role": {
"Fn::GetAtt": [
"BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC",
"Arn"
]
},
"Runtime": "nodejs8.10",
"Timeout": 300
}
}
}
}
28 changes: 28 additions & 0 deletions packages/@aws-cdk/aws-lambda/test/integ.bucket-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import s3 = require('@aws-cdk/aws-s3');
import cdk = require('@aws-cdk/cdk');
import lambda = require('../lib');

const app = new cdk.App(process.argv);

const stack = new cdk.Stack(app, 'lambda-bucket-notifications');

const bucketA = new s3.Bucket(stack, 'MyBucket');

const fn = new lambda.Function(stack, 'MyFunction', {
runtime: lambda.Runtime.NodeJS610,
handler: 'index.handler',
code: lambda.Code.inline(`exports.handler = ${handler.toString()}`)
});

const bucketB = new s3.Bucket(stack, 'YourBucket');

bucketA.onObjectCreated(fn, { suffix: '.png' });
bucketB.onEvent(s3.EventType.ObjectRemoved, fn);

process.stdout.write(app.run());

// tslint:disable:no-console
function handler(event: any, _context: any, callback: any) {
console.log(JSON.stringify(event, undefined, 2));
return callback(null, event);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class BucketNotifications extends cdk.Construct {

// resolve target. this also provides an opportunity for the target to e.g. update
// policies to allow this notification to happen.
const targetProps = target.asBucketNotificationDestination(this.bucket.bucketArn, this.bucket.path);
const targetProps = target.asBucketNotificationDestination(this.bucket.bucketArn, this.bucket.uniqueId);
const commonConfig: CommonConfiguration = {
Events: [ event ],
Filter: renderFilters(filters),
Expand Down

0 comments on commit 0cbb247

Please sign in to comment.