diff --git a/README.md b/README.md index b7be99f..3f63130 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,23 @@ automatically. Event subscriptions that are defined for a lambda function will be deployed per alias, i.e. the event will trigger the correct deployed aliased function. +### SNS + +Subscriptions to SNS topics can be implicitly defined by adding an `sns` event to +any existing lambda function definition. Serverless will create the topic for you +and add a subscription to the deployed function. + +With the alias plugin the subscription will be per alias. Additionally the created +topic is renamed and the alias name is added (e.g. myTopic-myAlias). This is done +because SNS topics are independent per stage. Imagine you want to introduce a new +topic or change the data/payload format of an existing one. Just attaching different +aliases to one central topic would eventually break the system, as functions from +different stages will receive the new data format. The topic-per-alias approach +effectively solves the problem. + +If you want to refer to the topic programmatically, you just can add `-${process.env.SERVERLESS_ALIAS}` +to the base topic name. + ### Use with global resources Event subscriptions can reference resources that are available throughout all diff --git a/lib/aliasRestructureStack.js b/lib/aliasRestructureStack.js index 3dc146e..ac7d56e 100644 --- a/lib/aliasRestructureStack.js +++ b/lib/aliasRestructureStack.js @@ -19,6 +19,7 @@ const userResources = require('./stackops/userResources'); const lambdaRole = require('./stackops/lambdaRole'); const events = require('./stackops/events'); const cwEvents = require('./stackops/cwEvents'); +const snsEvents = require('./stackops/snsEvents'); module.exports = { @@ -50,6 +51,10 @@ module.exports = { return cwEvents.call(this, currentTemplate, aliasStackTemplates, currentAliasStackTemplate); }, + aliasHandleSNSEvents(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) { + return snsEvents.call(this, currentTemplate, aliasStackTemplates, currentAliasStackTemplate); + }, + aliasFinalize(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) { const stageStack = this._serverless.service.provider.compiledCloudFormationTemplate; const aliasStack = this._serverless.service.provider.compiledCloudFormationAliasTemplate; @@ -84,6 +89,7 @@ module.exports = { .spread(this.aliasHandleApiGateway) .spread(this.aliasHandleEvents) .spread(this.aliasHandleCWEvents) + .spread(this.aliasHandleSNSEvents) .spread(this.aliasFinalize) .then(() => BbPromise.resolve()); } diff --git a/lib/stackops/snsEvents.js b/lib/stackops/snsEvents.js new file mode 100644 index 0000000..fa89e51 --- /dev/null +++ b/lib/stackops/snsEvents.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * Handle SNS Lambda subscriptions. + */ + +const _ = require('lodash'); +const BbPromise = require('bluebird'); +const utils = require('../utils'); + +module.exports = function(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) { + const stageStack = this._serverless.service.provider.compiledCloudFormationTemplate; + const aliasStack = this._serverless.service.provider.compiledCloudFormationAliasTemplate; + + this.options.verbose && this._serverless.cli.log('Processing SNS Lambda subscriptions'); + + const aliasResources = []; + + const aliases = _.assign({}, _.pickBy(aliasStack.Resources, [ 'Type', 'AWS::Lambda::Alias' ])); + const versions = _.assign({}, _.pickBy(aliasStack.Resources, [ 'Type', 'AWS::Lambda::Version' ])); + + // Add alias name to topics to disambiguate behavior + const snsTopics = + _.assign({}, + _.pickBy(stageStack.Resources, [ 'Type', 'AWS::SNS::Topic' ])); + + _.forOwn(snsTopics, (topic, name) => { + topic.DependsOn = topic.DependsOn || []; + // Remap lambda subscriptions + const lambdaSubscriptions = _.pickBy(topic.Properties.Subscription, ['Protocol', 'lambda']); + _.forOwn(lambdaSubscriptions, subscription => { + const functionNameRef = utils.findAllReferences(_.get(subscription, 'Endpoint')); + const functionName = _.replace(_.get(functionNameRef, '[0].ref', ''), /LambdaFunction$/, ''); + const versionName = _.find(_.keys(versions), version => _.startsWith(version, functionName)); + const aliasName = _.find(_.keys(aliases), alias => _.startsWith(alias, functionName)); + + subscription.Endpoint = { Ref: aliasName }; + + // Add dependency on function version + topic.DependsOn.push(versionName); + topic.DependsOn.push(aliasName); + }); + + topic.Properties.TopicName = `${topic.Properties.TopicName}-${this._alias}`; + + delete stageStack.Resources[name]; + }); + + // Fetch lambda permissions. These have to be updated later to allow the aliased functions. + const snsLambdaPermissions = + _.assign({}, + _.pickBy(_.pickBy(stageStack.Resources, [ 'Type', 'AWS::Lambda::Permission' ]), + [ 'Properties.Principal', 'sns.amazonaws.com' ])); + + // Adjust permission to reference the function aliases + _.forOwn(snsLambdaPermissions, (permission, name) => { + const functionName = _.replace(name, /LambdaPermission.*$/, ''); + const versionName = _.find(_.keys(versions), version => _.startsWith(version, functionName)); + const aliasName = _.find(_.keys(aliases), alias => _.startsWith(alias, functionName)); + + // Adjust references and alias permissions + permission.Properties.FunctionName = { Ref: aliasName }; + const sourceArn = _.get(permission.Properties, 'SourceArn.Fn::Join[1]', []); + sourceArn.push(`-${this._alias}`); + + // Add dependency on function version + permission.DependsOn = [ versionName, aliasName ]; + + delete stageStack.Resources[name]; + }); + + // Add all alias stack owned resources + aliasResources.push(snsTopics); + aliasResources.push(snsLambdaPermissions); + + _.forEach(aliasResources, resource => _.assign(aliasStack.Resources, resource)); + + return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]); +}; diff --git a/test/aliasRestructureStack.test.js b/test/aliasRestructureStack.test.js index c3f35f5..6b733d9 100644 --- a/test/aliasRestructureStack.test.js +++ b/test/aliasRestructureStack.test.js @@ -91,6 +91,7 @@ describe('aliasRestructureStack', () => { const aliasHandleApiGatewaySpy = sandbox.spy(awsAlias, 'aliasHandleApiGateway'); const aliasHandleEventsSpy = sandbox.spy(awsAlias, 'aliasHandleEvents'); const aliasHandleCWEventsSpy = sandbox.spy(awsAlias, 'aliasHandleCWEvents'); + const aliasHandleSNSEventsSpy = sandbox.spy(awsAlias, 'aliasHandleSNSEvents'); const aliasFinalizeSpy = sandbox.spy(awsAlias, 'aliasFinalize'); const currentTemplate = require('./data/sls-stack-2.json'); @@ -107,6 +108,7 @@ describe('aliasRestructureStack', () => { expect(aliasHandleApiGatewaySpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate), expect(aliasHandleEventsSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate), expect(aliasHandleCWEventsSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate), + expect(aliasHandleSNSEventsSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate), expect(aliasFinalizeSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate), ])); }); diff --git a/test/data/sns-stack.json b/test/data/sns-stack.json new file mode 100644 index 0000000..55ebbd4 --- /dev/null +++ b/test/data/sns-stack.json @@ -0,0 +1,358 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "The AWS CloudFormation template for this Serverless application", + "Resources": { + "ServerlessDeploymentBucket": { + "Type": "AWS::S3::Bucket" + }, + "Testfct1LogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": "/aws/lambda/sls-test-project-dev-testfct1" + } + }, + "IamRoleLambdaExecution": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + } + ] + }, + "Policies": [ + { + "PolicyName": { + "Fn::Join": [ + "-", + [ + "dev", + "sls-test-project", + "lambda" + ] + ] + }, + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream" + ], + "Resource": [ + { + "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/sls-test-project-dev-testfct1:*" + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "logs:PutLogEvents" + ], + "Resource": [ + { + "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/sls-test-project-dev-testfct1:*:*" + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "dynamodb:*" + ], + "Resource": [ + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn:aws:dynamodb", + { + "Ref": "AWS::Region" + }, + { + "Ref": "AWS::AccountId" + }, + "table" + ] + ] + }, + { + "Ref": "TestDynamoDbTable" + } + ] + ] + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:DescribeStream", + "dynamodb:ListStreams" + ], + "Resource": [ + { + "Fn::GetAtt": [ + "TestDynamoDbTable", + "StreamArn" + ] + } + ] + } + ] + } + } + ], + "Path": "/", + "RoleName": { + "Fn::Join": [ + "-", + [ + "sls-test-project", + "dev", + "us-east-1", + "lambdaRole" + ] + ] + } + } + }, + "Testfct1LambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "ServerlessDeploymentBucket" + }, + "S3Key": "serverless/sls-test-project/dev/1496054947737-2017-05-29T10:49:07.737Z/sls-test-project.zip" + }, + "FunctionName": "sls-test-project-dev-testfct1", + "Handler": "handlers/testfct1/handler.handle", + "MemorySize": 512, + "Role": { + "Fn::GetAtt": [ + "IamRoleLambdaExecution", + "Arn" + ] + }, + "Runtime": "nodejs4.3", + "Timeout": 15, + "Description": "Echo function echoes alias", + "Environment": { + "Variables": { + "SERVERLESS_PROJECT_NAME": "sls-test-project", + "SERVERLESS_PROJECT": "sls-test-project", + "SERVERLESS_STAGE": "dev", + "SERVERLESS_REGION": "us-east-1", + "TEST_TABLE_NAME": { + "Ref": "TestDynamoDbTable" + } + } + } + }, + "DependsOn": [ + "Testfct1LogGroup", + "IamRoleLambdaExecution" + ] + }, + "ApiGatewayRestApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "dev-sls-test-project" + } + }, + "ApiGatewayResourceFunc1": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId" + ] + }, + "PathPart": "func1", + "RestApiId": { + "Ref": "ApiGatewayRestApi" + } + } + }, + "ApiGatewayMethodFunc1Get": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "GET", + "RequestParameters": {}, + "ResourceId": { + "Ref": "ApiGatewayResourceFunc1" + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:aws:apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "Testfct1LambdaFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "MethodResponses": [] + } + }, + "SNSTopicSlstestprojecttopic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "sls-test-project-topic", + "DisplayName": "", + "Subscription": [ + { + "Endpoint": { + "Fn::GetAtt": [ + "Testfct1LambdaFunction", + "Arn" + ] + }, + "Protocol": "lambda" + } + ] + } + }, + "Testfct1LambdaPermissionSlstestprojecttopicSNS": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "FunctionName": { + "Fn::GetAtt": [ + "Testfct1LambdaFunction", + "Arn" + ] + }, + "Action": "lambda:InvokeFunction", + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:aws:sns:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + "sls-test-project-topic" + ] + ] + } + } + }, + "Testfct1EventSourceMappingDynamodbTestDynamoDbTable": { + "Type": "AWS::Lambda::EventSourceMapping", + "DependsOn": "IamRoleLambdaExecution", + "Properties": { + "BatchSize": 10, + "EventSourceArn": { + "Fn::GetAtt": [ + "TestDynamoDbTable", + "StreamArn" + ] + }, + "FunctionName": { + "Fn::GetAtt": [ + "Testfct1LambdaFunction", + "Arn" + ] + }, + "StartingPosition": "TRIM_HORIZON", + "Enabled": "True" + } + }, + "TestDynamoDbTable": { + "Type": "AWS::DynamoDB::Table", + "DeletionPolicy": "Delete", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "myKey", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "myKey", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + }, + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + } + } + }, + "Outputs": { + "ServerlessDeploymentBucketName": { + "Value": { + "Ref": "ServerlessDeploymentBucket" + } + }, + "ServiceEndpoint": { + "Description": "URL of the service endpoint", + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "ApiGatewayRestApi" + }, + ".execute-api.us-east-1.amazonaws.com/dev" + ] + ] + } + }, + "TestDynamoDbTableName": { + "Description": "Test DynamoDB Table Name", + "Value": { + "Ref": "TestDynamoDbTable" + } + } + } +} diff --git a/test/stackops/snsEvents.test.js b/test/stackops/snsEvents.test.js new file mode 100644 index 0000000..7e5c3bd --- /dev/null +++ b/test/stackops/snsEvents.test.js @@ -0,0 +1,84 @@ +'use strict'; +/** + * Unit tests for SNS events. + */ + +const getInstalledPath = require('get-installed-path'); +const BbPromise = require('bluebird'); +const chai = require('chai'); +const sinon = require('sinon'); +const AWSAlias = require('../../index'); + +const serverlessPath = getInstalledPath.sync('serverless', { local: true }); +const AwsProvider = require(`${serverlessPath}/lib/plugins/aws/provider/awsProvider`); +const Serverless = require(`${serverlessPath}/lib/Serverless`); + +chai.use(require('chai-as-promised')); +chai.use(require('sinon-chai')); +const expect = chai.expect; + +describe('SNS Events', () => { + let serverless; + let options; + let awsAlias; + // Sinon and stubs for SLS CF access + let sandbox; + let logStub; + + before(() => { + sandbox = sinon.sandbox.create(); + }); + + beforeEach(() => { + options = { + alias: 'myAlias', + stage: 'myStage', + region: 'us-east-1', + }; + serverless = new Serverless(options); + serverless.setProvider('aws', new AwsProvider(serverless)); + serverless.cli = new serverless.classes.CLI(serverless); + serverless.service.service = 'testService'; + serverless.service.provider.compiledCloudFormationAliasTemplate = {}; + awsAlias = new AWSAlias(serverless, options); + + // Disable logging + logStub = sandbox.stub(serverless.cli, 'log'); + logStub.returns(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#aliasHandleSNSEvents()', () => { + it('should succeed with standard template', () => { + serverless.service.provider.compiledCloudFormationTemplate = require('../data/sls-stack-1.json'); + serverless.service.provider.compiledCloudFormationAliasTemplate = require('../data/alias-stack-1.json'); + return expect(awsAlias.aliasHandleSNSEvents({}, [], {})).to.be.fulfilled; + }); + + it('should move resources to alias stack', () => { + const snsStack = serverless.service.provider.compiledCloudFormationTemplate = require('../data/sns-stack.json'); + const aliasStack = serverless.service.provider.compiledCloudFormationAliasTemplate = require('../data/alias-stack-1.json'); + return expect(awsAlias.aliasHandleSNSEvents({}, [], {})).to.be.fulfilled + .then(() => BbPromise.all([ + expect(snsStack).to.not.have.property('SNSTopicSlstestprojecttopic'), + expect(snsStack).to.not.have.property('Testfct1LambdaPermissionSlstestprojecttopicSNS'), + expect(aliasStack).to.not.have.property('SNSTopicSlstestprojecttopic'), + expect(aliasStack).to.not.have.property('Testfct1LambdaPermissionSlstestprojecttopicSNS'), + ])); + }); + + it('should replace function with alias reference', () => { + serverless.service.provider.compiledCloudFormationTemplate = require('../data/sns-stack.json'); + const aliasStack = serverless.service.provider.compiledCloudFormationAliasTemplate = require('../data/alias-stack-1.json'); + return expect(awsAlias.aliasHandleSNSEvents({}, [], {})).to.be.fulfilled + .then(() => BbPromise.all([ + expect(aliasStack).to.not.have.property('SNSTopicSlstestprojecttopic') + .that.has.deep.property('Properties.Subscription[0].Endpoint') + .that.deep.equals({ Ref: 'Testfct1Alias' }), + ])); + }); + }); +});