Completely rework the style of adding helpers

First one to change (as a POC): SnsSubscribe. Main changes:

1. Break each helper out into separate files.
2. Use a directory structure that puts non-AWS on par with AWS.
3. Provide an interface than can be implemented and makes CloudFormation
   stack deletion required.
4. Provide example templates (which can also be used to test the helpers).

I will migrate the other existing helpers in separate commits.
This commit is contained in:
Ryan Martin 2016-02-24 17:12:49 -05:00
parent 39fde92327
commit c81f9bca4d
9 changed files with 393 additions and 86 deletions

1
.gitignore vendored
View file

@ -28,3 +28,4 @@ node_modules
# Other local things to ignore
cloudformation-helpers.zip
.DS_Store

View file

@ -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:

32
aws/sns.js Normal file
View file

@ -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();
}

View file

@ -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){

View file

@ -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

113
lib/base.js Normal file
View file

@ -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)
}
);
}

79
lib/helpers.js Normal file
View file

@ -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;
}

21
package.json Normal file
View file

@ -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"
}
}

View file

@ -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"
]
}
}
}