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:
parent
39fde92327
commit
c81f9bca4d
9 changed files with 393 additions and 86 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -28,3 +28,4 @@ node_modules
|
|||
|
||||
# Other local things to ignore
|
||||
cloudformation-helpers.zip
|
||||
.DS_Store
|
||||
|
|
|
@ -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
32
aws/sns.js
Normal 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();
|
||||
}
|
|
@ -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){
|
||||
|
|
|
@ -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
113
lib/base.js
Normal 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
79
lib/helpers.js
Normal 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
21
package.json
Normal 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"
|
||||
}
|
||||
}
|
53
test/aws/sns.subscribe.template
Normal file
53
test/aws/sns.subscribe.template
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue