diff --git a/.gitignore b/.gitignore index 23da9f0..353e290 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ node_modules # Other local things to ignore cloudformation-helpers.zip +.DS_Store diff --git a/README.md b/README.md index b6408bc..4c6c196 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ S3PutObjectFunctionArn ### Subscribe to SNS topics Mirrors the SNS.Subscribe API method (http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SNS.html#subscribe-property). +To be used when the SNS topic already exists (since CloudFormation allows subscriptions to be created when creating SNS +topics only). #### Parameters @@ -126,9 +128,15 @@ The type of endpoint. Can be one of the following values: application, email, em ##### TopicArn The SNS topic to subscribe to. +#### Output +The result of the SNS.Subscribe API method, i.e. SubscriptionArn + #### Reference Output Name SnsSubscribeFunctionArn +#### Example/Test Template +[test/aws/sns.subscribe.template] + ## Deployment (contributors) After making changes (i.e. adding a new helper function), please do the following: diff --git a/aws/sns.js b/aws/sns.js new file mode 100644 index 0000000..baf0272 --- /dev/null +++ b/aws/sns.js @@ -0,0 +1,32 @@ +var Promise = require('bluebird'), + AWS = require('aws-sdk'), + base = require('lib/base'), + dynamoDB = Promise.promisifyAll(new AWS.DynamoDB()), + sns = Promise.promisifyAll(new AWS.SNS()); + +// Exposes the SNS.subscribe API method +function Subscribe(event, context, functionIdentifier) { + base.Handler.call(this, event, context, functionIdentifier); +} +Subscribe.prototype = Object.create(base.Handler.prototype); +Subscribe.prototype.handleCreate = function() { + var p = this.event.ResourceProperties; + return sns.subscribeAsync({ + Endpoint: p.Endpoint, + Protocol: p.Protocol, + TopicArn: p.TopicArn + }) +} +Subscribe.prototype.handleDelete = function(referenceData) { + if (referenceData) { + return sns.unsubscribeAsync({ + SubscriptionArn: referenceData.SubscriptionArn + }); + } else { + return Promise.try(function() {}); + } +} +exports.subscribe = function(event, context) { + handler = new Subscribe(event, context, "SnsSubscribeFunction"); + handler.handle(); +} diff --git a/cloudformation_helpers.js b/cloudformation_helpers.js index e78f022..c98f552 100644 --- a/cloudformation_helpers.js +++ b/cloudformation_helpers.js @@ -1,9 +1,31 @@ var AWS = require('aws-sdk'); -var dynamoDB = new AWS.DynamoDB(), +var Promise = require('bluebird'); +var apiGateway = Promise.promisifyAll(new AWS.APIGateway()), + dynamoDB = new AWS.DynamoDB(), response = require('./lib/cfn-response'), s3 = new AWS.S3(), sns = new AWS.SNS(); +exports.apiGatewayCreateRestApi = function(event, context) { + var p = event.ResourceProperties; + if (event.RequestType == 'Delete') { + // TODO: deleteRestApi here + response.send(event, context, response.SUCCESS); + } else { + apiGateway.createRestApiAsync({ + name: p.name, + cloneFrom: p.cloneFrom, + description: p.description + }) + .then(function(data) { + response.send(event, context, response.SUCCESS, { "data": data }); + }) + .catch(function(err) { + error(err, event, context); + }); + } +} + exports.dynamoDBPutItems = function(event, context) { var p = event.ResourceProperties; if (event.RequestType == 'Delete') { @@ -48,38 +70,6 @@ exports.s3PutObject = function(event, context) { }); } -// Exposes the SNS.subscribe API method -exports.snsSubscribe = function(event, context) { - const allowedProtocols = ['application', 'email', 'email-json', 'http', 'https', 'lambda', 'sms', 'sqs']; - var p = event.ResourceProperties; - if (event.RequestType == 'Delete') { - // TODO: Would be nice to delete the subscription here, which likely requires persisting the - // SubscriptionArn for later lookup. - response.send(event, context, response.SUCCESS); - return; - } - - if (!p.TopicArn) { - error("Must specify a TopicArn to subscribe to.", event, context); - } else if (!p.Endpoint) { - error("Must specify an Endpoint that receives messages.", event, context); - } else if (allowedProtocols.indexOf(p.Protocol) < 0) { - error("Must speficy one of these supported protocols: " + allowedProtocols, event, context); - } else { - sns.subscribe({ - Endpoint: p.Endpoint, - Protocol: p.Protocol, - TopicArn: p.TopicArn - }, function(err, data) { - if (err) { - error(err, event, context); - } else { - response.send(event, context, response.SUCCESS, { "SubscriptionArn": data.SubscriptionArn }); - } - }); - } -} - // Puts items into DynamoDB, iterating over the list recursively. function putItems(items, tableName, event, context, itemsInserted) { if(items.length > 0){ diff --git a/create_cloudformation_helper_functions.template b/create_cloudformation_helper_functions.template index cfe3c74..91788f6 100644 --- a/create_cloudformation_helper_functions.template +++ b/create_cloudformation_helper_functions.template @@ -1,6 +1,57 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Resources": { + "ReferenceDB": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "key", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + }, + "TableName": { "Fn::Join": [ "-", [ { "Ref" : "AWS::StackName" }, "reference" ] ] } + } + }, + "RoleBasePolicy": { + "Type": "AWS::IAM::ManagedPolicy", + "Properties": { + "Description" : { "Fn::Join": [ " ", [ "Base policy for all Lambda function roles in", { "Ref" : "AWS::StackName" }, "." ] ] }, + "PolicyDocument" : { + "Version" : "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:*:*:*" + }, + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Scan" + ], + "Resource": { "Fn::Join": [ "", [ "arn:aws:dynamodb:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" } , ":table/", { "Ref": "ReferenceDB" } ] ] } + } + ] + } + } + }, "DynamoDBPutItemsFunctionRole": { "Type": "AWS::IAM::Role", "Properties": { @@ -16,24 +67,10 @@ } ] }, + "ManagedPolicyArns": [ + { "Ref": "RoleBasePolicy" } + ], "Policies": [ - { - "PolicyName": "LogWriter", - "PolicyDocument": { - "Version" : "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "arn:aws:logs:*:*:*" - } - ] - } - }, { "PolicyName": "DBWriter", "PolicyDocument": { @@ -84,24 +121,10 @@ } ] }, + "ManagedPolicyArns": [ + { "Ref": "RoleBasePolicy" } + ], "Policies": [ - { - "PolicyName": "LogWriter", - "PolicyDocument": { - "Version" : "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "arn:aws:logs:*:*:*" - } - ] - } - }, { "PolicyName": "S3Writer", "PolicyDocument": { @@ -153,24 +176,10 @@ } ] }, + "ManagedPolicyArns": [ + { "Ref": "RoleBasePolicy" } + ], "Policies": [ - { - "PolicyName": "LogWriter", - "PolicyDocument": { - "Version" : "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "arn:aws:logs:*:*:*" - } - ] - } - }, { "PolicyName": "SNSSubscriber", "PolicyDocument": { @@ -179,7 +188,8 @@ { "Effect": "Allow", "Action": [ - "sns:subscribe" + "sns:subscribe", + "sns:unsubscribe" ], "Resource": "*" } @@ -197,7 +207,7 @@ "S3Key": "lambda_functions/cloudformation-helpers.zip" }, "Description": "Used to subscribe to existing SNS topics.", - "Handler": "cloudformation_helpers.snsSubscribe", + "Handler": "aws/sns.subscribe", "Role": {"Fn::GetAtt" : [ "SnsSubscribeFunctionRole", "Arn" ] }, "Runtime": "nodejs", "Timeout": 30 diff --git a/lib/base.js b/lib/base.js new file mode 100644 index 0000000..69ad416 --- /dev/null +++ b/lib/base.js @@ -0,0 +1,113 @@ +// Implement this class for every new handler. + +var Promise = require('bluebird'), + helpers = require('lib/helpers'), + response = require('lib/cfn-response'), + AWS = require('aws-sdk'), + dynamoDB = Promise.promisifyAll(new AWS.DynamoDB()); + +exports.Handler = function(event, context, functionIdentifier) { + this.event = event; + this.context = context; + this.functionIdentifier = functionIdentifier; +} + +exports.Handler.prototype.handle = function() { + var outer = this; + Promise.try(function() { + switch (outer.event.RequestType) { + case 'Create': + return outer.handleCreate() + .then(function(data) { + return outer.setReferenceData(data) + .then(function() { + return data; + }); + }); + case 'Delete': + return outer.getReferenceData() + .then(function(data) { + return outer.handleDelete(data); + }); + case 'Update': + return outer.getReferenceData() + .then(function(data) { + return outer.handleUpdate(); + }); + default: + throw "Unrecognized RequestType [" + outer.event.RequestType + "]"; + } + }) + .then(function(data) { + response.send(outer.event, outer.context, response.SUCCESS, data); + }) + .catch(function(err) { + outer.error(err); + }); +} + +/* + When implemented, these should all return a Promise, which will then be completed by the handle() + method above. + + NB: These methods are named 'handle*' because 'delete' is a reserved word in Javascript and + can't be overridden. To ensure naming parity, they have been named with the 'handle' prefix. +*/ +exports.Handler.prototype.handleCreate = function() { + throw "create method not implemented"; +} + +exports.Handler.prototype.handleDelete = function(referenceData) { + throw "delete method not implemented"; +} + +exports.Handler.prototype.handleUpdate = function(referenceData) { + return this.handleDelete(referenceData) + .then(function() { + return this.handleCreate(); + }); +} + +exports.Handler.prototype.error = function(message) { + console.error(message); + response.send(this.event, this.context, response.FAILED, { Error: message }); + throw message; +} + +exports.Handler.prototype.getStackName = function() { + var i = this.context.functionName.indexOf("-" + this.functionIdentifier); + if (this.functionIdentifier && i >= 0) + return this.context.functionName.substr(0, i); + else + return this.context.functionName; +} + +exports.Handler.prototype.getReferenceData = function() { + return dynamoDB.getItemAsync( + { + TableName: this.getStackName() + "-reference", + Key: helpers.formatForDynamo({ + key: this.event.StackId + this.event.LogicalResourceId + }, true) + } + ) + .then(function(data) { + data = helpers.formatFromDynamo(data); + if (data && data.Item && data.Item.value) + return data.Item.value; + else + return null; + }) +} + +exports.Handler.prototype.setReferenceData = function(data) { + return dynamoDB.putItemAsync( + { + TableName: this.getStackName() + "-reference", + Item: helpers.formatForDynamo({ + key: this.event.StackId + this.event.LogicalResourceId, + value: data + }, true) + } + ); +} \ No newline at end of file diff --git a/lib/helpers.js b/lib/helpers.js new file mode 100644 index 0000000..82f43a3 --- /dev/null +++ b/lib/helpers.js @@ -0,0 +1,79 @@ +exports.formatForDynamo = function(value, topLevel) { + var result = undefined; + if (value == 'true' || value == 'false') { + result = {'BOOL': value == 'true'} + } else if (!isNaN(value) && value.trim() != '') { + result = {'N': value} + } else if (Array.isArray(value)) { + var arr = []; + for (var i = 0; i < value.length; i++) { + arr.push(exports.formatForDynamo(value[i], false)); + } + result = {'L': arr}; + } else if (typeof value === "object") { + var map = {}; + Object.keys(value).forEach(function(key) { + map[key] = exports.formatForDynamo(value[key], false) + }); + if (topLevel) result = map; + else result = {'M': map} + } else { + result = {'S': value} + } + return result; +} + +exports.formatFromDynamo = function(value) { + var result = undefined; + if (typeof value === "string" || typeof value === 'boolean' || typeof value === 'number') { + result = value; + } else if (Array.isArray(value)) { + var arr = []; + for (var i = 0; i < value.length; i++) { + arr.push(exports.formatFfromDynamo(value[i])); + } + result = arr; + } else if (typeof value === "object") { + var map = {}; + Object.keys(value).forEach(function(key) { + var v = exports.formatFromDynamo(value[key]); + switch (key) { + case 'B': + throw "Unsupported Mongo type [B]"; + case 'BOOL': + result = (v == 'true'); + break; + case 'BS': + throw "Unsupported Mongo type [BS]"; + case 'L': + result = v; + break; + case 'M': + result = v; + break; + case 'N': + result = Number(v); + break; + case 'NS': + result = []; + for (var i = 0; i < v.length; i++) { + result.push(Number(value[i])); + }; + break; + case 'S': + result = v; + break; + case 'SS': + result = v; + break; + default: + map[key] = v; + } + if (result === undefined) + result = map; + }); + } else { + throw "Unrecognized type [" + (typeof value) + "]"; + } + return result; +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9dad8b4 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "cloudformation-helpers", + "version": "0.0.0", + "description": "A set of helper methods to fill in the gaps in existing CloudFormation support.", + "main": "cloudformation_helpers.js", + "repository": { + "type": "git", + "url": "https://github.com/gilt/cloudformation-helpers" + }, + "keywords": [ + "cloudformation" + ], + "author": "Ryan Martin", + "license": "Apache 2.0", + "bugs": { + "url": "https://github.com/gilt/cloudformation-helpers/issues" + }, + "dependencies": { + "bluebird": "3.x" + } +} diff --git a/test/aws/sns.subscribe.template b/test/aws/sns.subscribe.template new file mode 100644 index 0000000..0af254c --- /dev/null +++ b/test/aws/sns.subscribe.template @@ -0,0 +1,53 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "CFHelperStackName": { + "Type": "String", + "Description": "The name of the stack where you installed the CloudFormation helper functions. See https://github.com/gilt/cloudformation-helpers." + } + }, + "Resources": { + "CFHelperStack": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "TemplateURL": "https://s3.amazonaws.com/com.gilt.public.backoffice/cloudformation_templates/lookup_stack_outputs.template" + } + }, + "CFHelper": { + "Type": "Custom::CFHelper", + "Properties": { + "ServiceToken": { "Fn::GetAtt" : ["CFHelperStack", "Outputs.LookupStackOutputsArn"] }, + "StackName": { "Ref": "CFHelperStackName" } + }, + "DependsOn": [ + "CFHelperStack" + ] + }, + "OriginQueue": { + "Type" : "AWS::SNS::Topic", + "Properties" : { + "TopicName" : { "Fn::Join": [ "-", [ { "Ref" : "AWS::StackName" }, "origin" ] ] } + } + }, + "NotificationQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { "Fn::Join": [ "-", [ { "Ref" : "AWS::StackName" }, "notifications" ] ] } + } + }, + "SnsSubscription": { + "Type": "Custom::SnsSubscription", + "Properties": { + "ServiceToken": { "Fn::GetAtt" : ["CFHelper", "SnsSubscribeFunctionArn"] }, + "TopicArn": { "Ref": "OriginQueue" }, + "Protocol": "sqs", + "Endpoint": { "Fn::GetAtt" : [ "NotificationQueue", "Arn" ] } + }, + "DependsOn": [ + "CFHelper", + "OriginQueue", + "NotificationQueue" + ] + } + } +} \ No newline at end of file