The Problem

Recently we faced an issue when trying to add an S3 Notification Configuration for a Lambda function with an existing S3 Bucket. The bucket may need x number of Notification Configurations adding to it in the future. The Lambda functions are deployed using infrastructure as code services AWS SAM (Serverless Application Model) and CloudFormation. In the process of creating an integration we needed to add a notifcation to an existing S3 bucket so that the Lambda was triggered every time a file was created in a specific folder.

Reading the S3 EventSource documentation on AWS it states that to add an S3 Event Notification to as an event source that a “…bucket must exist in the same template.”. We faced an issue here that the bucket is already deployed an exists in another template on a different stack.

The Search for a Solution

A proposed solution was to add the Notification Configuration on the existing template (Stack A) which seems straight forward enough. However, there are some quirks with this as to get this set up you have to deploy the new stack which requires the notification (Stack B) with a AWS::Lambda::Permission first which references Stack A. Then you deploy the S3 Notification Configuration on Stack A which references Stack B. This seems like a lot of messing around for lazy developers and these steps could be forgotten for future developments. It also results in Stack A having knowledge of lots of other stacks which is doesn’t need to know about.

Time to turn to Google and find out how others had solved this issue, Amazon have a great article in the Knowledge Center which gives the following solution. The idea is that you can add a Lambda function which can be triggered as a custom CloudFormation resource.

Custom resources enable you to write custom provisioning logic in templates that AWS CloudFormation runs anytime you create, update (if you changed the custom resource), or delete stacks. For example, you might want to include resources that aren’t available as AWS CloudFormation resource types. You can include those resources by using custom resources. That way you can still manage all your related resources in a single stack.

Custom resources, AWS

Is is really going to be this easy?

You would think this was pretty simple now, you have the solution and you can just copy the code and implement it and you’ve solved another problem… This didn’t turn out to be the truth as their are a number of quirks to the s3:PutBucketNotification API.

During an initial investigation, I deployed a stack which had the S3 Bucket and the CloudFormation Custom Resource Lambda from the AWS Solution. I then deployed a secondary stack which could utilise the Lambda to add an S3 Notification Configuration. This contained the basic Hello World Lambda and a AWS::Lambda::Permission resource alongside the Custom resource which would trigger the Lambda which added notifications to the S3 Bucket.

This all went really smoothly, as a requirement of the solution was that multiple projects could add their notifications I figured it would make sense to add another stack that used the S3 Bucket Notification custom resource. After it deployed successfully I checked the S3 Bucket properties tab and expected to find 2 Event notifications entries. There was only 1 entry which reference the second stack which had been deployed.

After some debugging it turns out that the s3:PutBucketNotification API does not append new entries, it will overwrite them as with most PUT operations. So unfortunately this meant it wasn’t going to be as easy as take the Amazon Solution from above and use it. I would need to extend the soultion and build my own custom resource Lambda to handle the notifications.

Building the S3 Notification Configuration Lambda

The original example was writting in python, I work predominantly with node.js so I started from scratch and created the Lambda using JavaScript.

The following snippet is the Lambda handler which is executed whenever the function is triggered in AWS. A custom resource event contains a payload which tells you what type of request is being made from CloudFormation (Create, Update or Delete – more on Delete later…) plus a ResourceProperties object which contains parameters which can be passed in from the CloudFormation template.

To get the desired outcome the following parameters are passed into the function:

  • S3 Bucket ARN which to notification will be added to
  • Lambda ARN which will be notified when changes happen
  • Filter Value which determines which Amazon S3 objects invoke the Lambda function
  • Events an array of S3 events which will trigger the rule

After extracting the values the ‘RequestType‘ is checked, if it is ‘Delete‘ it will trigger the ‘deleteNotification‘ function or for any other request types it will use the ‘createNotification‘ function. You can find more details on these below.

If the function runs successfully the ‘responseStatus‘ is set to ‘SUCCESS‘ and this is passed to the cfn-response module – “the module contains a send method, which sends a response object to a custom resource by way of an Amazon S3 presigned URL (the ResponseURL)”. If any errors occurr then the ‘responseStatus‘ is set to ‘FAILED‘ and the CloudFormation deployment will be rolled back to it’s original state.

exports.lambdaHandler = async (event, context) => {
  let responseData, responseStatus;
  const bucket = event["ResourceProperties"]["Bucket"];
  const lambdaArn = event["ResourceProperties"]["LambdaArn"];
  const filterValue = event["ResourceProperties"]["FilterValue"];
  const events = event["ResourceProperties"]["Events"];
  try {
    if (event["RequestType"] == "Delete") {
      await deleteNotification(lambdaArn, bucket, filterValue, events);
    } else {
      await createNotification(lambdaArn, bucket, filterValue, events);
      responseData = { Bucket: bucket };
    }
    responseStatus = "SUCCESS";
  } catch (error) {
    console.error(error);
    responseStatus = "FAILED";
    responseData = { Failure: error.message };
  }
  return cfnResponse.send(event, context, responseStatus, responseData);
};

Create Notification Configuration Function

The following snippet is the createNotification function which will add you desired configuration to the S3 bucket. It uses some helper functions which are shared between this functions and the deleteNotification function. If you want to dive deeper into these functions then get the source code and look at how they are implemented. The interesting part was that the current configuration needed to be fetched and filtered so that any existing configurations for other Lambda’s were not removed during the update.

The execution steps of the function were:

  • Get the existing configurations for the S3 Bucket
  • Filter the config so that configurations for the Lambda ARN are removed
  • Generate a new Lambda Function configuration
  • Generate the parameter object for the putBucketNotificationConfiguration api
  • Call the s3.putBucketNotificationConfiguration api with the params
async function createNotification(lambdaArn, bucket, filterValue, events) {
  const { LambdaFunctionConfigurations: lambdaConfig } = await getConfig(
    bucket
  );
  const filteredConfig = await filterConfig(lambdaConfig, lambdaArn);
  const notification = createLambdaConfig(lambdaArn, filterValue, events);
  const notifcationParams = createParams(
    [...filteredConfig, notification],
    bucket
  );
  return s3.putBucketNotificationConfiguration(notifcationParams).promise();
}

Delete Notification Configuration Function

The deleteNotification function is called when the resource is removed from a CloudFormation template or the stack is deleted. The call which caused the biggest headache to the whole process came when a resource is updated – if the FilterValue or Events were changed it would trigger the update call which adds the new S3 Event Notification. However, during the CloudFormation update process, it then removes the old resource which triggered the deleteNotifcation call which removed the newly updated configuration.

The solution was to filter the existing configuration and when it found the configuration with the same Lambda ARN to check if the values for the FilterValue and Events had changed from the old resource. If they had changed this would mean that it had been updated and wasn’t a true delete call – where the resource parameters shoud be the same

The execution steps of the function were:

  • Get the existing Lambda Function Configuration
  • Filter the Lambda configuration and check if it was updated – if there was an update then do not remove the configuration.
  • Generate the parameter object for the putBucketNotificationConfiguration api
  • Call the s3.putBucketNotificationConfiguration api with the params
async function deleteNotification(lambdaArn, bucket, filterValue, events) {
  const { LambdaFunctionConfigurations: lambdaConfig } = await getConfig(
    bucket
  );
  const filteredLambdaConfigParams = await checkForConfigUpdate(
    lambdaConfig,
    lambdaArn,
    filterValue,
    events
  );
  let updatedConfig = createParams(filteredLambdaConfigParams, bucket);
  return s3.putBucketNotificationConfiguration(updatedConfig).promise();
}

CloudFormation Templates

The following block of code comes from the Lambda function template which requires the notification. The BucketPermission resource sets up the permissions for the Lambda and allows the S3 Bucket to trigger notification on the Lambda. The S3BucketArn is exported from the existing S3 Bucket CloudFormation stack and is imported here.

The LambdaTrigger block imports the ServiceToken from the existing S3 Cloudformation template. The Properties block is what will be passed into the S3 Notification Configuration Lambda. These properties will result in the Lambda being triggered anytime an object is created in the /notifyMe/ folder of the bucket.

  BucketPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt NotificationFunction.Arn
      Action: lambda:InvokeFunction
      Principal: s3.amazonaws.com
      SourceAccount: !Ref 'AWS::AccountId'
      SourceArn: !ImportValue S3BucketArn

  LambdaTrigger:
    Type: 'Custom::LambdaTrigger'
    DependsOn: BucketPermission
    Properties:
      ServiceToken: !ImportValue AddNotificationLambdaArn
      LambdaArn: !GetAtt NotificationFunction.Arn
      Bucket: !ImportValue S3BucketName
      FilterValue: /notifyMe/
      Events: 
        - "s3:ObjectCreated:*"

GitHub Repository

You can find the full code examples in the following GitHub repository:

https://github.com/danielBroadhurst/customS3NotificationTrigger

The s3Bucket folder contains the function and the CloudFormation template which will deploy a bucket and the S3 Notification Configuration Lambda. The CloudFormation will create a bucket, an IAM Role and the Lambda. It will export the necessary values which can be used in any other stacks.

The notifcation folder contains the Hello World sample Lambda which will be triggered by the S3 Notification. The function is deployed using a CloudFormation template which contains the AWS::Lambda::Permission resource and the S3 Notification Configuration Lambda resource.

Did you find this useful?

If you found this useful or have a better solution to this problem then please leave a comment or get in touch. I would love to dicuss this topic further.

Thank you for taking the time to read this far.