├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .release-it.json ├── LICENSE ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json └── src └── serverless_plugin_subscription_filter.js /.eslintignore: -------------------------------------------------------------------------------- 1 | index.js 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb", 3 | "plugins": [ 4 | "import" 5 | ], 6 | "env": { 7 | "browser": false, 8 | "node": true 9 | }, 10 | "rules": { 11 | "no-unused-vars": [2, { "argsIgnorePattern": "^_" }], 12 | "max-len": ["error", 150] 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/c0c1a480a906df0e023f3250cf2ad82f1612be67/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | ### Developer added 63 | 64 | index.js 65 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .env 2 | .envrc 3 | .eslintcache 4 | .eslintignore 5 | .eslintrc.js 6 | .release-it.json 7 | babel.config.js 8 | src 9 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "tagName": "v${version}" 4 | }, 5 | "github": { 6 | "release": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Tsubasa Takayama 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-plugin-subscription-filter 2 | 3 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) [![npm version](https://badge.fury.io/js/serverless-plugin-subscription-filter.svg)](https://badge.fury.io/js/serverless-plugin-subscription-filter) 4 | 5 | Serverless plugin to register AWS CloudWatchLogs subscription filter. 6 | 7 | ## Installation 8 | 9 | `npm install --save-dev serverless-plugin-subscription-filter` 10 | 11 | ```yaml 12 | plugins: 13 | - serverless-plugin-subscription-filter 14 | ``` 15 | 16 | ## Usage 17 | 18 | This plugin is external serverless events. 19 | You can write settings like serverless events. 20 | 21 | ```yaml 22 | functions: 23 | hello: 24 | handler: handler.hello 25 | events: 26 | - subscriptionFilter: 27 | stage: prod 28 | logGroupName: /cloud-trail 29 | filterPattern: '{ $.errorMessage != "" }' 30 | ``` 31 | 32 | Supports also multiple subscription filter. 33 | 34 | ```yaml 35 | functions: 36 | hello: 37 | handler: handler.hello 38 | events: 39 | - subscriptionFilter: 40 | stage: prod 41 | logGroupName: /cloud-trail 42 | filterPattern: '{ $.errorMessage != "" }' 43 | - subscriptionFilter: 44 | stage: prod 45 | logGroupName: /my-log-group 46 | filterPattern: '{ $.errorMessage != "" }' 47 | goodbye: 48 | handler: handler.goodbye 49 | events: 50 | - subscriptionFilter: 51 | stage: dev 52 | logGroupName: /my-log-group2 53 | filterPattern: Exception 54 | ``` 55 | 56 | ### About each properties 57 | 58 | |property|description| 59 | |:---:|:---:| 60 | |stage|The deployment stage with serverless. Because only one subscription filter can be set for one LogGroup.| 61 | |logGroupName|The log group to associate with the subscription filter. | 62 | |filterPattern|The filtering expressions that restrict what gets delivered to the destination AWS resource. Sorry, if you want to use '{ $.xxx = "yyy" }' syntax, then surround the whole in ''(single quote).| 63 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | api.cache(true); 3 | 4 | const presets = [ 5 | ['@babel/preset-env', { 6 | targets: { 7 | node: 4.3, 8 | }, 9 | }], 10 | ]; 11 | 12 | return { presets }; 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-plugin-subscription-filter", 3 | "version": "1.0.7", 4 | "description": "serverless-plugin-subscription-filter", 5 | "main": "index.js", 6 | "repository": "ssh://git@github.com/tsub/serverless-plugin-subscription-filter", 7 | "author": "tsub ", 8 | "license": "MIT", 9 | "bugs": { 10 | "url": "https://github.com/tsub/serverless-plugin-subscription-filter/issues" 11 | }, 12 | "homepage": "https://github.com/tsub/serverless-plugin-subscription-filter#readme", 13 | "keywords": [ 14 | "serverless", 15 | "plugin", 16 | "subscription-filter", 17 | "cloudwatchlogs" 18 | ], 19 | "dependencies": { 20 | "aws-sdk": "^2.496.0", 21 | "lodash": "^4.17.2" 22 | }, 23 | "devDependencies": { 24 | "@babel/cli": "^7.5.5", 25 | "@babel/core": "^7.5.5", 26 | "@babel/preset-env": "^7.5.5", 27 | "eslint": "^4.18.2", 28 | "eslint-config-airbnb": "^14.1.0", 29 | "eslint-plugin-import": "^2.2.0", 30 | "eslint-plugin-jsx-a11y": "^3.0.2 || ^4.0.0", 31 | "eslint-plugin-react": "^6.9.0", 32 | "release-it": "^12.3.3" 33 | }, 34 | "scripts": { 35 | "build": "babel src --out-file index.js", 36 | "watch": "npm run build -- --watch", 37 | "prepublishOnly": "npm run build", 38 | "lint": "eslint --cache ." 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/serverless_plugin_subscription_filter.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const AWS = require('aws-sdk'); 3 | 4 | class ServerlessPluginSubscriptionFilter { 5 | constructor(serverless, options) { 6 | this.serverless = serverless; 7 | this.options = options; 8 | 9 | this.provider = this.serverless.getProvider('aws'); 10 | AWS.config.update({ 11 | region: this.serverless.service.provider.region, 12 | }); 13 | 14 | this.hooks = { 15 | 'deploy:compileEvents': this.compileSubscriptionFilterEvents.bind(this), 16 | }; 17 | } 18 | 19 | compileSubscriptionFilterEvents() { 20 | const stage = this.provider.getStage(); 21 | const functions = this.serverless.service.getAllFunctions(); 22 | const promises = []; 23 | 24 | this.preCheckResourceLimitExceeded(functions); 25 | 26 | functions.forEach((functionName) => { 27 | const functionObj = this.serverless.service.getFunction(functionName); 28 | 29 | functionObj.events.forEach((event) => { 30 | const subscriptionFilter = event.subscriptionFilter; 31 | 32 | if (this.validateSettings(subscriptionFilter)) { 33 | if (subscriptionFilter.stage !== stage) { 34 | // Skip compile 35 | this.serverless.cli.log(`Skipping to compile ${subscriptionFilter.logGroupName} subscription filter object...`); 36 | return; 37 | } 38 | 39 | promises.push(this.doCompile(subscriptionFilter, functionName)); 40 | } 41 | }); 42 | }); 43 | 44 | return Promise.all(promises); 45 | } 46 | 47 | validateSettings(setting) { 48 | if (!setting) { 49 | // Skip compile 50 | return false; 51 | } 52 | 53 | if (!setting.stage || typeof setting.stage !== 'string') { 54 | const errorMessage = [ 55 | 'You can\'t set stage properties of a subscriptionFilter event.', 56 | 'stage propertiy is required.', 57 | ].join(' '); 58 | throw new this.serverless.classes.Error(errorMessage); 59 | } 60 | 61 | if (!setting.logGroupName || typeof setting.logGroupName !== 'string') { 62 | const errorMessage = [ 63 | 'You can\'t set logGroupName properties of a subscriptionFilter event.', 64 | 'logGroupName propertiy is required.', 65 | ].join(' '); 66 | throw new this.serverless.classes.Error(errorMessage); 67 | } 68 | 69 | if (!setting.filterPattern || typeof setting.filterPattern !== 'string') { 70 | const errorMessage = [ 71 | 'You can\'t set filterPattern properties of a subscriptionFilter event.', 72 | 'filterPattern propertiy is required.', 73 | ].join(' '); 74 | throw new this.serverless.classes.Error(errorMessage); 75 | } 76 | 77 | return true; 78 | } 79 | 80 | doCompile(setting, functionName) { 81 | this.serverless.cli.log(`Compiling ${setting.logGroupName} subscription filter object...`); 82 | 83 | return this.checkResourceLimitExceeded(setting.logGroupName, functionName) 84 | .then(_data => this.getLogGroupArn(setting.logGroupName)) 85 | .then(logGroupArn => this.compilePermission(setting, functionName, logGroupArn)) 86 | .then((newPermissionObject) => { 87 | _.merge( 88 | this.serverless.service.provider.compiledCloudFormationTemplate.Resources, 89 | newPermissionObject, 90 | ); 91 | 92 | return this.compileSubscriptionFilter(setting, functionName); 93 | }) 94 | .then((newSubscriptionFilterObject) => { 95 | _.merge( 96 | this.serverless.service.provider.compiledCloudFormationTemplate.Resources, 97 | newSubscriptionFilterObject, 98 | ); 99 | }) 100 | .catch((err) => { 101 | throw new this.serverless.classes.Error(err.message); 102 | }); 103 | } 104 | 105 | preCheckResourceLimitExceeded(functions) { 106 | const logGroupNames = _.flatMap(functions, (functionName) => { 107 | const functionObj = this.serverless.service.getFunction(functionName); 108 | 109 | return functionObj.events; 110 | }).filter(event => event.subscriptionFilter) 111 | .map(event => event.subscriptionFilter.logGroupName); 112 | 113 | _.mapKeys(_.countBy(logGroupNames), (value, key) => { 114 | if (value > 1) { 115 | const errorMessage = ` 116 | Subscription filters of ${key} log group 117 | 118 | - Resource limit exceeded.. 119 | 120 | You've hit a AWS resource limit: 121 | http://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html 122 | 123 | Subscription filters: 1/log group. This limit cannot be changed. 124 | `; 125 | throw new this.serverless.classes.Error(errorMessage); 126 | } 127 | }); 128 | } 129 | 130 | checkResourceLimitExceeded(logGroupName, functionName) { 131 | return new Promise((resolve, reject) => { 132 | const lambdaFunctionName = this.buildLambdaFunctionName(functionName); 133 | const promises = [ 134 | ServerlessPluginSubscriptionFilter.getSubscriptionFilterDestinationArn(logGroupName), 135 | this.guessSubscriptionFilterDestinationArn(logGroupName, lambdaFunctionName), 136 | ]; 137 | 138 | Promise.all(promises) 139 | .then((data) => { 140 | const subscriptionFilterDestinationArn = data[0]; 141 | const guessedSubscriptionFilterDestinationArn = data[1]; 142 | 143 | if (!subscriptionFilterDestinationArn) { 144 | return resolve(); 145 | } 146 | 147 | if (subscriptionFilterDestinationArn !== guessedSubscriptionFilterDestinationArn) { 148 | const errorMessage = ` 149 | Subscription filters of ${logGroupName} log group 150 | 151 | - Resource limit exceeded.. 152 | 153 | You've hit a AWS resource limit: 154 | http://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html 155 | 156 | Subscription filters: 1/log group. This limit cannot be changed. 157 | `; 158 | 159 | return reject(new this.serverless.classes.Error(errorMessage)); 160 | } 161 | 162 | return resolve(); 163 | }) 164 | .catch((err) => { 165 | reject(err); 166 | }); 167 | }); 168 | } 169 | 170 | compileSubscriptionFilter(setting, functionName) { 171 | return new Promise((resolve, _reject) => { 172 | const lambdaLogicalId = this.provider.naming.getLambdaLogicalId(functionName); 173 | const lambdaPermissionLogicalId = this.getLambdaPermissionLogicalId(functionName, setting.logGroupName); 174 | const filterPattern = ServerlessPluginSubscriptionFilter.escapeDoubleQuote(setting.filterPattern); 175 | const logGroupName = setting.logGroupName; 176 | const subscriptionFilterTemplate = ` 177 | { 178 | "Type" : "AWS::Logs::SubscriptionFilter", 179 | "Properties" : { 180 | "DestinationArn" : { "Fn::GetAtt": ["${lambdaLogicalId}", "Arn"] }, 181 | "FilterPattern" : "${filterPattern}", 182 | "LogGroupName" : "${logGroupName}" 183 | }, 184 | "DependsOn": "${lambdaPermissionLogicalId}" 185 | } 186 | `; 187 | const subscriptionFilterLogicalId = this.getSubscriptionFilterLogicalId(functionName, setting.logGroupName); 188 | const newSubscriptionFilterObject = { 189 | [subscriptionFilterLogicalId]: JSON.parse(subscriptionFilterTemplate), 190 | }; 191 | 192 | resolve(newSubscriptionFilterObject); 193 | }); 194 | } 195 | 196 | compilePermission(setting, functionName, logGroupArn) { 197 | return new Promise((resolve, _reject) => { 198 | const lambdaLogicalId = this.provider.naming.getLambdaLogicalId(functionName); 199 | const region = this.provider.getRegion(); 200 | const permissionTemplate = ` 201 | { 202 | "Type": "AWS::Lambda::Permission", 203 | "Properties": { 204 | "FunctionName": { "Fn::GetAtt": ["${lambdaLogicalId}", "Arn"] }, 205 | "Action": "lambda:InvokeFunction", 206 | "Principal": "logs.${region}.amazonaws.com", 207 | "SourceArn": "${logGroupArn}" 208 | } 209 | } 210 | `; 211 | const lambdaPermissionLogicalId = this.getLambdaPermissionLogicalId(functionName, setting.logGroupName); 212 | const newPermissionObject = { 213 | [lambdaPermissionLogicalId]: JSON.parse(permissionTemplate), 214 | }; 215 | 216 | resolve(newPermissionObject); 217 | }); 218 | } 219 | 220 | getLogGroupArn(logGroupName, nextToken = null) { 221 | return new Promise((resolve, reject) => { 222 | const cloudWatchLogs = new AWS.CloudWatchLogs(); 223 | const params = { 224 | logGroupNamePrefix: logGroupName, 225 | nextToken, 226 | }; 227 | 228 | cloudWatchLogs.describeLogGroups(params).promise() 229 | .then((data) => { 230 | const logGroups = data.logGroups; 231 | if (logGroups.length === 0) { 232 | return reject(new Error('LogGroup not found')); 233 | } 234 | 235 | const logGroup = _.find(logGroups, { logGroupName }); 236 | if (!logGroup) { 237 | return this.getLogGroupArn(logGroupName, data.nextToken); 238 | } 239 | 240 | return resolve(logGroup.arn); 241 | }) 242 | .catch((err) => { 243 | reject(err); 244 | }); 245 | }); 246 | } 247 | 248 | getSubscriptionFilterLogicalId(functionName, logGroupName) { 249 | const normalizedFunctionName = this.provider.naming.getNormalizedFunctionName(functionName); 250 | const normalizedLogGroupName = this.provider.naming.normalizeNameToAlphaNumericOnly(logGroupName); 251 | 252 | return `${normalizedFunctionName}SubscriptionFilter${normalizedLogGroupName}`; 253 | } 254 | 255 | getLambdaPermissionLogicalId(functionName, logGroupName) { 256 | const normalizedFunctionName = this.provider.naming.getNormalizedFunctionName(functionName); 257 | const normalizedLogGroupName = this.provider.naming.normalizeNameToAlphaNumericOnly(logGroupName); 258 | 259 | return `${normalizedFunctionName}LambdaPermission${normalizedLogGroupName}`; 260 | } 261 | 262 | buildLambdaFunctionName(functionName) { 263 | const serviceName = this.serverless.service.getServiceName(); 264 | const stage = this.provider.getStage(); 265 | 266 | return `${serviceName}-${stage}-${functionName}`; 267 | } 268 | 269 | guessSubscriptionFilterDestinationArn(logGroupName, functionName) { 270 | return new Promise((resolve, reject) => { 271 | const region = this.provider.getRegion(); 272 | 273 | this.provider.getAccountId() 274 | .then((accountId) => { 275 | resolve(`arn:aws:lambda:${region}:${accountId}:function:${functionName}`); 276 | }) 277 | .catch((err) => { 278 | reject(err); 279 | }); 280 | }); 281 | } 282 | 283 | static getSubscriptionFilterDestinationArn(logGroupName) { 284 | return new Promise((resolve, reject) => { 285 | const cloudWatchLogs = new AWS.CloudWatchLogs(); 286 | const params = { 287 | logGroupName, 288 | }; 289 | 290 | cloudWatchLogs.describeSubscriptionFilters(params).promise() 291 | .then((data) => { 292 | if (data.subscriptionFilters.length === 0) { 293 | return resolve(); 294 | } 295 | 296 | return resolve(data.subscriptionFilters[0].destinationArn); 297 | }) 298 | .catch((err) => { 299 | reject(err); 300 | }); 301 | }); 302 | } 303 | 304 | static escapeDoubleQuote(str) { 305 | return str.replace(/"/g, '\\"'); 306 | } 307 | } 308 | 309 | module.exports = ServerlessPluginSubscriptionFilter; 310 | --------------------------------------------------------------------------------