├── .babelrc ├── .codeclimate.yml ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dist ├── Pm2Module.js ├── WebhookServer.js ├── index.js └── utils │ ├── c.js │ ├── index.js │ ├── isPromise.js │ └── log.js ├── package.json ├── src ├── Pm2Module.js ├── WebhookServer.js ├── index.js └── utils │ ├── c.js │ ├── index.js │ ├── isPromise.js │ └── log.js └── test ├── Pm2Module.js ├── WebhookServer.js ├── assets ├── c.js ├── callApi.js ├── chai.js ├── expect.js ├── index.js └── mockSpawn.js ├── mocks ├── apps.js ├── bitbucket_push.json └── github_push.json └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - javascript 8 | eslint: 9 | enabled: true 10 | fixme: 11 | enabled: true 12 | ratings: 13 | paths: 14 | - "**.js" 15 | exclude_paths: 16 | - dist/ 17 | - test/ 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/mocks -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb"], 3 | "rules": { 4 | "max-len": [1, 120, 2, {"ignoreComments": true}], 5 | "quote-props": [1, "consistent-as-needed"], 6 | "no-cond-assign": [2, "except-parens"], 7 | "radix": 0, 8 | "space-infix-ops": 0, 9 | "no-unused-vars": [1, {"vars": "local", "args": "none"}], 10 | "default-case": 0, 11 | "no-else-return": 0, 12 | "no-param-reassign": 0, 13 | "quotes": 0, 14 | "indent": [2, 4], 15 | "comma-dangle": [1, "never"], 16 | "consistent-return": [0], 17 | "no-underscore-dangle": [0], 18 | "class-methods-use-this": 0, 19 | "no-use-before-define": 0, 20 | "prefer-const": 0, 21 | "arrow-body-style": 0, 22 | "no-unused-expressions": 0, 23 | "func-names": 0, 24 | "no-plusplus": 0, 25 | "no-console": 0, 26 | "one-var": 0, 27 | "one-var-declaration-per-line": 0, 28 | "no-continue": 0, 29 | "no-restricted-syntax": 0, 30 | "no-prototype-builtins": 0, 31 | "padded-blocks": 0, 32 | "prefer-promise-reject-errors": 0, 33 | "prefer-destructuring": 0, 34 | "object-curly-newline": 0 35 | }, 36 | "env": { 37 | "node": true, 38 | "mocha": true, 39 | "jasmine": true 40 | } 41 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Idea folder 40 | .idea 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | - "lts/*" 5 | - "node" 6 | install: 7 | - npm install 8 | - npm run lint 9 | - npm run test 10 | - npm run build 11 | after_success: 12 | - npm run test:cover 13 | - npm install codeclimate-test-reporter 14 | - ./node_modules/codeclimate-test-reporter/bin/codeclimate.js < coverage/lcov.info 15 | addons: 16 | code_climate: 17 | repo_token: 0af2f54eeda3b061cef721d9235e333f47b93f78d1b4112da46ccc0eb52f5e52 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## 1.1.13/14 - 2019-07-12 5 | ### Fix 6 | - Respond to the webhook server without waiting the command to end. Fixes issues with Github Webhook timeout(10s). 7 | 8 | ## 1.1.12 - 2019-07-06 9 | ### Fix 10 | - Hooks with secret not working as not being able to hash the raw body 11 | 12 | ## 1.1.10/11 - 2019-07-06 13 | ### Fix 14 | - Issue with bitbucket body not being parsed correctly 15 | 16 | ### Change 17 | - Now travis test on multiple node versions 18 | 19 | ## 1.1.9 - 2018-08-05 20 | ### Fix 21 | - Issue with github webhook without secret, not receiving the header 22 | 23 | ## 1.1.5-8 - 2018-06-01 24 | ### Change 25 | - Upgrade dependencies 26 | 27 | ### Fix 28 | - Readme typo 29 | 30 | ## 1.1.4 - 2017-03-07 31 | ### Add 32 | - Initial support for bitbucket 33 | 34 | ## 1.1.2-1.1.3 - 2017-03-04 35 | ### Add 36 | - Initial secret support for github 37 | 38 | ## 1.1.0-1.1.1 - 2017-03-04 39 | ### Add 40 | - CWD configuration support 41 | 42 | ## 1.0.7 - 2017-02-26 43 | ### Add 44 | - Badges 45 | 46 | ## 1.0.6 - 2017-02-26 47 | ### Add 48 | - Babel 49 | - Readme 50 | 51 | ## 1.0.5 - 2017-02-26 52 | ### Add 53 | - Log service 54 | 55 | ## 1.0.4 - 2017-02-26 56 | ### Add 57 | - Run of a simple command 58 | 59 | ## 1.0.3 - 2017-02-26 60 | ### Add 61 | - Index on root folder 62 | 63 | ## 1.0.2 - 2017-02-25 64 | ### Add 65 | - Basics on Pm2Module 66 | - More tests to WebhookServer 67 | 68 | ## 1.0.1 - 2017-02-23 69 | ### Add 70 | - Test and lint 71 | 72 | ## 1.0.0 - 2017-02-23 73 | ### Add 74 | - Base of the project 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Roger Fos Soler 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 | # pm2-hooks 2 | 3 | [![GitHub version][fury-badge]][fury-url] 4 | [![Travis-CI][travis-badge]][travis-url] 5 | [![Codeclimate][codeclimate-badge]][codeclimate-url] 6 | [![Codeclimate Coverage][codeclimate-cov-badge]][codeclimate-cov-url] 7 | [![Dependency][david-badge]][david-url] 8 | [![DevDependency][david-dev-badge]][david-dev-url] 9 | 10 | PM2 module to listen webhooks from github, bitbucket, gitlab, jenkins and droneci. When a webhook is received you can run a script, pull your project, restart pm2, etc. 11 | 12 | This project is highly inspired by [vmarchaud/pm2-githook](https://github.com/vmarchaud/pm2-githook). 13 | 14 | Features ([changelog](/CHANGELOG.md)): 15 | 16 | - Runs an http server listening for webhooks 17 | - Works on any repository system, as for now it does nothing with the payload received. In the near future I will check the branch or the action, the secret, etc. 18 | - Only runs the command you set 19 | - Run the command in the cwd defined for the app 20 | 21 | Wanted features, to be done during Mach/2017: 22 | 23 | - Check payload for secret, check common headers on main git repositories (github, bitbucket, gitlab, etc) to know if is a valid call 24 | 25 | Possible features, as I need to think about it: 26 | 27 | - Auto-restart pm2 app after a successful command run 28 | - Make an automatic `git pull` on the folder, and make a `prePull` and `postPull` available commands (same approach as [vmarchaud/pm2-githook](https://github.com/vmarchaud/pm2-githook)) 29 | 30 | # Install 31 | 32 | To install it simply run: 33 | 34 | ```bash 35 | $ pm2 install pm2-hooks 36 | ``` 37 | 38 | Warning: This library currently (2017 feb 26) is in ALPHA state. This means some things: 39 | 40 | - You can help me a lot making a comment/issue/PR 41 | - I will try to publish the last version to npm so you can install it with only `pm2 install pm2-hooks`. If for some reason the version on npm is outdated you always will be capable of run `pm2 install desaroger/pm2-hooks` to be sure to install the last version directly from the repository. 42 | 43 | 44 | # Usage 45 | 46 | ## Step 1: Prepare the ecosystem file 47 | 48 | By default **pm2-hooks** doesn't do anything. You need to set the key `env_hook` inside the config of a given app, inside the ecosystem file. 49 | 50 | If env_hook isn't defined or is falsy then is disabled. 51 | 52 | Example of an ecosystem file: 53 | 54 | ```js 55 | { 56 | apps: [ 57 | { 58 | name: 'api-1', 59 | script: 'server.js' 60 | }, 61 | { 62 | name: 'api-2', 63 | script: 'server.js', 64 | env_hook: { 65 | command: 'git pull && npm i && npm test && pm2 restart api-2', 66 | cwd: '/home/desaroger' 67 | } 68 | } 69 | ] 70 | } 71 | ``` 72 | 73 | Where **api-1** has hook disabled and **api-2** is enabled and when the hook is called, the command is executed. 74 | 75 | ### Available options: 76 | 77 | - **command** *{string}* The line you want to execute. Will be executed with NodeJS `spawn`. (optional, but if not set this is not going to do nothing ¯\\_(ツ)_/¯) 78 | - **cwd** *{string}* The cwd to use when running the command. If not set, the one used on your ecosystem app configuration will be used (if set). 79 | - **commandOptions** *{object}* The object that we will pass to the NodeJS `spawn`. Defaults to a blank object, and later we add the *cwd*. 80 | - **type** *{string}* [not implemented yet] The git server you are going to use [github, gitlab, bitbucket, etc]. 81 | 82 | ## Step 2: Install 83 | 84 | If you didn't install before, install it. If you installed it, then you will need to restart it. For that, run `pm2 restart pm2-hooks`. 85 | 86 | ## Step 3: Try it 87 | 88 | Now you have a server on port 9000 by default. You can make a call to `http://localhost:9000/api-2` to see the response. 89 | 90 | If everything went fine, you will see: 91 | 92 | ```js 93 | { 94 | status: 'success', 95 | message: 'Route "api-2" was found' 96 | code: 0 97 | } 98 | ``` 99 | 100 | And the command had been executed. 101 | 102 | ## Step 4: See the log 103 | 104 | Everything will be logged in the pm2 logs. For see them, run: 105 | 106 | ```bash 107 | $ pm2 logs pm2-hooks 108 | ``` 109 | 110 | And for see the entire log: 111 | 112 | ```bash 113 | $ cat ~/.pm2/logs/pm2-hooks-out-0.log 114 | ``` 115 | 116 | # FAQ 117 | 118 | ## How I can change the port? 119 | 120 | You can set the port (where the default port is 9000) setting it in the config of the pm2 module. For doing that, run: 121 | 122 | ```bash 123 | $ pm2 set pm2-hooks:port 3000 124 | ``` 125 | 126 | ## How I can uninstall it? 127 | 128 | You can uninstall this module running: 129 | 130 | ```bash 131 | $ pm2 uninstall pm2-hooks 132 | ``` 133 | 134 | # Another similar projects 135 | 136 | These are some projects I found similar to mine. Please let me know if you know anoher. 137 | 138 | - [vmarchaud/pm2-githook](https://github.com/vmarchaud/pm2-githook): From where I was inspired. It works on any repository, pulls the repo when webhook is called and has *preHook* and *postHook* commands available. 139 | - [oowl/pm2-webhook](https://github.com/oowl/pm2-webhook): Works on any repository. If you want to use the *secret*, then the webhook must contain the *X-Hub-Signature* in order to work (I don't know if every git server contains it). 140 | 141 | # Copyright and license 142 | 143 | Copyright 2017 Roger Fos Soler 144 | 145 | Licensed under the [MIT License](/LICENSE). 146 | 147 | 148 | [npm-badge]: https://img.shields.io/npm/v/pm2-hooks.svg 149 | [npm-url]: https://www.npmjs.com/package/pm2-hooks 150 | 151 | [fury-badge]: https://badge.fury.io/js/pm2-hooks.svg 152 | [fury-url]: https://www.npmjs.com/package/pm2-hooks 153 | 154 | [travis-badge]: https://travis-ci.org/desaroger/pm2-hooks.svg 155 | [travis-url]: https://travis-ci.org/desaroger/pm2-hooks 156 | 157 | [david-badge]: https://david-dm.org/desaroger/pm2-hooks.svg 158 | [david-url]: https://david-dm.org/desaroger/pm2-hooks 159 | [david-dev-badge]: https://david-dm.org/desaroger/pm2-hooks/dev-status.svg 160 | [david-dev-url]: https://david-dm.org/desaroger/pm2-hooks#info=devDependencies 161 | 162 | [gemnasium-badge]: https://gemnasium.com/badges/github.com/desaroger/pm2-hooks.svg 163 | [gemnasium-url]: https://gemnasium.com/github.com/desaroger/pm2-hooks 164 | 165 | [codeclimate-badge]: https://codeclimate.com/github/desaroger/pm2-hooks/badges/gpa.svg 166 | [codeclimate-url]: https://codeclimate.com/github/desaroger/pm2-hooks 167 | 168 | [codeclimate-cov-badge]: https://codeclimate.com/github/desaroger/pm2-hooks/badges/coverage.svg?hash=1 169 | [codeclimate-cov-url]: https://codeclimate.com/github/desaroger/pm2-hooks/coverage 170 | 171 | [coverage-badge]: https://codeclimate.com/github/desaroger/pm2-hooks/badges/coverage.svg 172 | [coverage-url]: https://codeclimate.com/github/desaroger/pm2-hooks/coverage 173 | -------------------------------------------------------------------------------- /dist/Pm2Module.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 4 | 5 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 6 | 7 | /** 8 | * Created by desaroger on 25/02/17. 9 | */ 10 | 11 | var _ = require('lodash'); 12 | var childProcess = require('child_process'); 13 | var WebhookServer = require('./WebhookServer'); 14 | 15 | var _require = require('./utils'), 16 | log = _require.log; 17 | 18 | var Pm2Module = function () { 19 | function Pm2Module() { 20 | var processes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; 21 | var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 22 | 23 | _classCallCheck(this, Pm2Module); 24 | 25 | options.routes = Pm2Module._parseProcesses(processes); 26 | this.routes = options.routes; 27 | this.webhookServer = new WebhookServer(options); 28 | } 29 | 30 | _createClass(Pm2Module, [{ 31 | key: 'start', 32 | value: function start() { 33 | var _this = this; 34 | 35 | return this.webhookServer.start().then(function () { 36 | var msg = 'Started. Routes:\n'; 37 | _.forOwn(_this.routes, function (route, name) { 38 | msg += ' - ' + name + ': ' + JSON.stringify(route) + '\n'; 39 | }); 40 | log(msg); 41 | }); 42 | } 43 | }, { 44 | key: 'stop', 45 | value: function stop() { 46 | return this.webhookServer.stop().then(function () { 47 | log('Stopped.'); 48 | }); 49 | } 50 | 51 | /** 52 | * Converts an array of PM2 processes to an object structured 53 | * for the WebhookServer routes. It internally uses the _parseProcess 54 | * method 55 | * 56 | * Example 1: 57 | * - input: 58 | * [ 59 | * { pm2_env: { env_hook: { name: 'api', type: 'bitbucket' } } }, 60 | * { pm2_env: { env_hook: { name: 'panel', type: 'github' } } } 61 | * ] 62 | * - output: 63 | * { 64 | * api: { type: 'bitbucket' }, 65 | * panel: { type: 'github' } 66 | * } 67 | * 68 | * @param processes 69 | * @returns {*} 70 | * @private 71 | */ 72 | 73 | }], [{ 74 | key: '_parseProcesses', 75 | value: function _parseProcesses(processes) { 76 | return processes.map(function (p) { 77 | return Pm2Module._parseProcess(p); 78 | }).filter(function (p) { 79 | return !!p; 80 | }).reduce(function (routes, app) { 81 | routes[app.name] = app; 82 | delete app.name; 83 | return routes; 84 | }, {}); 85 | } 86 | 87 | /** 88 | * Converts a PM2 process object to an object for WebhookServer 89 | * route. 90 | * 91 | * Example 1: 92 | * - input: { pm2_env: { env_hook: { name: 'api', type: 'bitbucket' } } } 93 | * - output: { name: 'api', type: 'bitbucket' } 94 | * Example 2: 95 | * - input: { pm2_env: { env_hook: { type: 'bitbucket' } } } 96 | * - output: { name: 'unknown', type: 'bitbucket' } 97 | * 98 | * @param app The Pm2 process 99 | * @returns {object|null} The route object, or null if invalid 100 | * @private 101 | */ 102 | 103 | }, { 104 | key: '_parseProcess', 105 | value: function _parseProcess(app) { 106 | // Check data 107 | if (!app || !app.pm2_env) { 108 | return null; 109 | } 110 | var config = app.pm2_env.env_hook; 111 | if (!config) { 112 | log('No options found for "' + app.name + '" route'); 113 | return null; 114 | } 115 | if (config === true) { 116 | config = {}; 117 | } 118 | 119 | // Config to WebhookServer route 120 | var self = this; 121 | var name = app.name || 'unknown'; 122 | var cwd = config.cwd || app.pm_cwd || app.pm2_env.cwd || app.pm2_env.pm_cwd; 123 | var commandOptions = Object.assign({}, { cwd: cwd }, config.commandOptions || {}); 124 | var route = { 125 | name: name, 126 | type: config.type, 127 | secret: config.secret, 128 | method: function method(payload) { 129 | log('Parsed payload: ' + JSON.stringify(payload)); 130 | try { 131 | if (config.command) { 132 | log('Running command: ' + config.command); 133 | self._runCommand(config.command, commandOptions).catch(function (e) { 134 | return onError(name, e); 135 | }); 136 | } 137 | } catch (e) { 138 | onError(name, e); 139 | } 140 | } 141 | }; 142 | route = cleanObj(route); 143 | 144 | return route; 145 | 146 | function onError(routeName, e) { 147 | var err = e.message || e; 148 | log('Error on "' + name + '" route: ' + err, 2); 149 | throw e; 150 | } 151 | } 152 | 153 | /** 154 | * Runs a line command. 155 | * 156 | * @param {String} command The line to execute 157 | * @param {Object} options The object options 158 | * @returns {Promise} The code of the error, or a void fulfilled promise 159 | * @private 160 | */ 161 | 162 | }, { 163 | key: '_runCommand', 164 | value: function _runCommand(command) { 165 | var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 166 | 167 | _.defaults(options, { 168 | env: process.env, 169 | shell: true 170 | }); 171 | return new Promise(function (resolve, reject) { 172 | var child = childProcess.spawn('eval', [command], options); 173 | child.on('close', function (code) { 174 | if (!code) { 175 | resolve(); 176 | } else { 177 | reject(code); 178 | } 179 | }); 180 | }); 181 | } 182 | }]); 183 | 184 | return Pm2Module; 185 | }(); 186 | 187 | module.exports = Pm2Module; 188 | 189 | function cleanObj(obj) { 190 | return _(obj).omitBy(_.isUndefined).value(); 191 | } -------------------------------------------------------------------------------- /dist/WebhookServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 4 | 5 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 6 | 7 | /** 8 | * Created by desaroger on 23/02/17. 9 | */ 10 | 11 | var _ = require('lodash'); 12 | var http = require('http'); 13 | var bodyParser = require('body-parser'); 14 | var crypto = require('crypto'); 15 | // const ipaddr = require('ipaddr.js'); 16 | 17 | var _require = require('./utils'), 18 | log = _require.log, 19 | c = _require.c, 20 | isPromise = _require.isPromise; 21 | 22 | var WebhookServer = function () { 23 | 24 | /** 25 | * @param {object} options The options 26 | * @constructor 27 | */ 28 | function WebhookServer() { 29 | var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 30 | 31 | _classCallCheck(this, WebhookServer); 32 | 33 | _.defaults(options, { 34 | port: process.env.PORT || 9000, 35 | routes: {} 36 | }); 37 | for (var name in options.routes) { 38 | /* istanbul ignore next */ 39 | if (!options.routes.hasOwnProperty(name)) continue; 40 | options.routes[name].name = name; 41 | } 42 | this.options = options; 43 | } 44 | 45 | /** 46 | * Creates the server and start it (with .listen) 47 | * 48 | * @returns {Promise} Returns the instance 49 | */ 50 | 51 | 52 | _createClass(WebhookServer, [{ 53 | key: 'start', 54 | value: function start() { 55 | var _this = this; 56 | 57 | return new Promise(function (resolve, reject) { 58 | if (_this.server) { 59 | return reject('Server previously started'); 60 | } 61 | _this.server = http.createServer(_this._handleCall.bind(_this)); 62 | _this.server.listen(_this.options.port, function (err) { 63 | /* istanbul ignore next */ 64 | if (err) return reject(err); 65 | resolve(_this); 66 | }); 67 | }); 68 | } 69 | 70 | /** 71 | * Stops the server. Doesn't throws an error if fails. 72 | * 73 | * @returns {Promise} Returns the instance 74 | */ 75 | 76 | }, { 77 | key: 'stop', 78 | value: function stop() { 79 | var _this2 = this; 80 | 81 | return new Promise(function (resolve) { 82 | if (!_this2.server) { 83 | return resolve(_this2); 84 | } 85 | _this2.server.close(function () { 86 | delete _this2.server; 87 | resolve(_this2); 88 | }); 89 | }); 90 | } 91 | 92 | /** 93 | * Parses the body of a request 94 | * 95 | * @param {http.IncomingMessage} req The Request of the call 96 | * @param {http.ServerResponse} res The Response of the call 97 | * @param {function} fn The function to call after the body has been parsed 98 | * @private 99 | */ 100 | 101 | }, { 102 | key: '_parseBody', 103 | value: function _parseBody(req, res, fn) { 104 | var rawBodyParser = function rawBodyParser(req2, res2, buf, encoding) { 105 | if (buf && buf.length) { 106 | req2.rawBody = buf.toString(encoding || 'utf8'); 107 | console.log("rawBody", req2.rawBody); 108 | } else { 109 | console.log("nope"); 110 | } 111 | }; 112 | 113 | bodyParser.urlencoded({ 114 | extended: true, 115 | verify: rawBodyParser 116 | })(req, res, function () { 117 | bodyParser.json({ 118 | verify: rawBodyParser 119 | })(req, res, function () { 120 | bodyParser.raw({ 121 | type: function type() { 122 | return true; 123 | }, 124 | verify: rawBodyParser 125 | })(req, res, fn); 126 | }); 127 | }); 128 | } 129 | 130 | /** 131 | * This method is the main function of the http server. 132 | * Given a request, it finds out the matched route and 133 | * calls the method of the route. 134 | * 135 | * @param {http.IncomingMessage} req The Request of the call 136 | * @param {http.ServerResponse} res The Response of the call 137 | * @private 138 | */ 139 | 140 | }, { 141 | key: '_handleCall', 142 | value: function _handleCall(req, res) { 143 | var self = this; 144 | this._parseBody(req, res, c( /*#__PURE__*/regeneratorRuntime.mark(function _callee() { 145 | var routeName, route, payload, result, message; 146 | return regeneratorRuntime.wrap(function _callee$(_context) { 147 | while (1) { 148 | switch (_context.prev = _context.next) { 149 | case 0: 150 | _context.prev = 0; 151 | 152 | // Mock 153 | routeName = self._getRouteName(req); 154 | 155 | if (routeName) { 156 | _context.next = 7; 157 | break; 158 | } 159 | 160 | log('No route found on url', 1); 161 | res.statusCode = 400; 162 | res.end(JSON.stringify({ 163 | status: 'warning', 164 | message: 'No route found on url', 165 | code: 1 166 | })); 167 | return _context.abrupt('return'); 168 | 169 | case 7: 170 | route = self.options.routes[routeName]; 171 | 172 | if (route) { 173 | _context.next = 13; 174 | break; 175 | } 176 | 177 | log('Warning: Route "' + routeName + '" not found', 1); 178 | res.statusCode = 400; 179 | res.end(JSON.stringify({ 180 | status: 'warning', 181 | message: 'Route "' + routeName + '" not found', 182 | code: 1 183 | })); 184 | return _context.abrupt('return'); 185 | 186 | case 13: 187 | 188 | // Prepare the execution of the method 189 | payload = self._parseRequest(req, route); 190 | result = void 0; 191 | _context.prev = 15; 192 | 193 | result = route.method(payload); 194 | 195 | if (!isPromise(result)) { 196 | _context.next = 21; 197 | break; 198 | } 199 | 200 | _context.next = 20; 201 | return result; 202 | 203 | case 20: 204 | result = _context.sent; 205 | 206 | case 21: 207 | _context.next = 30; 208 | break; 209 | 210 | case 23: 211 | _context.prev = 23; 212 | _context.t0 = _context['catch'](15); 213 | message = _context.t0.message ? _context.t0.message : _context.t0; 214 | 215 | log('Error: Route "' + routeName + '" method error: ' + message, 2); 216 | res.statusCode = 500; 217 | res.end(JSON.stringify({ 218 | status: 'error', 219 | message: 'Route "' + routeName + '" method error: ' + message, 220 | code: 2 221 | })); 222 | return _context.abrupt('return'); 223 | 224 | case 30: 225 | 226 | log('Success: Route "' + routeName + '" was found', 0); 227 | res.end(JSON.stringify({ 228 | status: 'success', 229 | message: 'Route "' + routeName + '" was found', 230 | code: 0, 231 | result: result 232 | })); 233 | _context.next = 39; 234 | break; 235 | 236 | case 34: 237 | _context.prev = 34; 238 | _context.t1 = _context['catch'](0); 239 | 240 | log(_context.t1.message, 2); 241 | res.statusCode = 500; 242 | res.end(JSON.stringify({ 243 | status: 'error', 244 | message: 'Unexpected error: ' + _context.t1.message, 245 | code: 2 246 | })); 247 | 248 | case 39: 249 | case 'end': 250 | return _context.stop(); 251 | } 252 | } 253 | }, _callee, this, [[0, 34], [15, 23]]); 254 | }))); 255 | } 256 | 257 | /** 258 | * Given a request, returns the matched route 259 | * 260 | * @param {http.IncomingMessage} req The Request of the call 261 | * @returns {string|null} The matched route or null if not 262 | * @private 263 | */ 264 | 265 | }, { 266 | key: '_getRouteName', 267 | value: function _getRouteName(req) { 268 | var path = trimSlashes(req.url); 269 | var name = path.split('/')[0]; 270 | name = name.split('?')[0]; 271 | if (!name) { 272 | return null; 273 | } 274 | 275 | return name; 276 | } 277 | }, { 278 | key: '_parseRequest', 279 | value: function _parseRequest(req, route) { 280 | route = Object.assign({ 281 | name: '', 282 | type: 'auto', 283 | secret: false 284 | }, route); 285 | 286 | req.headers = _.transform(req.headers, function (result, val, key) { 287 | result[key.toLowerCase()] = val; 288 | }); 289 | 290 | // Auto-type 291 | if (route.type === 'auto') { 292 | if (req.headers['x-github-event']) { 293 | route.type = 'github'; 294 | } else if (req.headers['user-agent'] && /bitbucket/i.test(req.headers['user-agent'])) { 295 | route.type = 'bitbucket'; 296 | } 297 | if (route.type === 'auto') { 298 | route.type = false; 299 | } 300 | } 301 | 302 | // Checks 303 | var result = {}; 304 | if (route.type) { 305 | var checksMap = { 306 | github: function github() { 307 | var error = false; 308 | if (!req.headers['x-github-event'] || route.secret && !req.headers['x-hub-signature']) { 309 | error = 'Invalid headers'; 310 | } else if (route.secret) { 311 | if (!req.rawBody) { 312 | error = 'Secret required and body not found on request'; 313 | } else { 314 | var hash = crypto.createHmac('sha1', route.secret); 315 | hash = hash.update(req.rawBody).digest('hex'); 316 | if ('sha1=' + hash !== req.headers['x-hub-signature']) { 317 | error = 'Invalid secret'; 318 | } 319 | } 320 | } 321 | return error; 322 | }, 323 | bitbucket: function bitbucket() { 324 | var error = false; 325 | var requiredHeaders = ['x-event-key', 'x-request-uuid']; 326 | if (requiredHeaders.some(function (k) { 327 | return !req.headers[k]; 328 | })) { 329 | error = 'Invalid headers'; 330 | } else if (route.secret) { 331 | error = 'Secret not supported for bitbucket yet'; 332 | } else { 333 | error = 'Invalid body'; 334 | if (req.body && _.isObjectLike(req.body)) { 335 | var action = req.headers['x-event-key'].replace('repo:', ''); 336 | if (!action) { 337 | error = 'Invalid headers'; 338 | } else if (req.body[action]) { 339 | error = false; 340 | } 341 | } 342 | } 343 | return error; 344 | }, 345 | test: function test() {} 346 | }; 347 | var method = checksMap[route.type]; 348 | if (!method) { 349 | var _error = 'Error unknown route type ' + route.type; 350 | log(_error, 2); 351 | throw new Error(_error); 352 | } 353 | 354 | var error = method(); 355 | if (error) { 356 | error = 'Error for route type ' + route.type + ': ' + error; 357 | log(error, 2); 358 | throw new Error(error); 359 | } 360 | 361 | // Parse 362 | var body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; 363 | body = body || {}; 364 | var parseMap = { 365 | github: function github() { 366 | if (body.payload) { 367 | body = body.payload; 368 | } 369 | if (typeof body === 'string') { 370 | body = JSON.parse(body); 371 | } 372 | result.name = _.get(body, 'repository.name'); 373 | result.action = req.headers['x-github-event']; 374 | result.branch = _.get(body, 'ref', '').replace('refs/heads/', '') || false; 375 | }, 376 | bitbucket: function bitbucket() { 377 | result.action = req.headers['x-event-key'].replace('repo:', ''); 378 | result.name = _.get(body, 'repository.name'); 379 | result.branch = ''; 380 | var changes = _.get(body, result.action + '.changes') || []; 381 | var validChange = changes.reduce(function (total, change) { 382 | total = total.concat([change.old, change.new]); 383 | return total; 384 | }, []).filter(function (change) { 385 | return change && change.type === 'branch'; 386 | }).find(function (change) { 387 | return !!change.name; 388 | }); 389 | if (!validChange) { 390 | error = 'Error for route type ' + route.type + ': Invalid "changes" key on body'; 391 | log(error, 2); 392 | throw new Error(error); 393 | } 394 | result.branch = validChange.name; 395 | }, 396 | test: function test() { 397 | result = body; 398 | } 399 | }; 400 | parseMap[route.type](); 401 | } 402 | 403 | return result; 404 | } 405 | 406 | /** 407 | * Checks if the internal server is running. 408 | * 409 | * @returns {boolean} True if the server is running 410 | */ 411 | 412 | }, { 413 | key: 'isRunning', 414 | value: function isRunning() { 415 | return !!(this.server && this.server.address()); 416 | } 417 | }]); 418 | 419 | return WebhookServer; 420 | }(); 421 | 422 | module.exports = WebhookServer; 423 | 424 | function trimSlashes(s) { 425 | s = s.trim().replace(/(^\/)|(\/$)/, ''); 426 | return s; 427 | } -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Created by desaroger on 21/02/17. 5 | */ 6 | 7 | var pmx = require('pmx'); 8 | var pm2 = require('pm2'); 9 | var Pm2Module = require('./Pm2Module'); 10 | require('babel-regenerator-runtime'); 11 | 12 | pmx.initModule({}, function (errPMX, conf) { 13 | pm2.connect(function (errPM2) { 14 | if (errPMX || errPM2) { 15 | console.error(errPMX || errPM2); // eslint-disable-line no-console 16 | return; 17 | } 18 | pm2.list(function (err, apps) { 19 | var pm2Module = new Pm2Module(apps, conf); 20 | pm2Module.start(); 21 | }); 22 | }); 23 | }); -------------------------------------------------------------------------------- /dist/utils/c.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Created by desaroger on 23/02/17. 5 | */ 6 | 7 | var co = require('co'); 8 | 9 | var c = co.wrap; 10 | c.run = co; 11 | 12 | module.exports = c; -------------------------------------------------------------------------------- /dist/utils/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Created by desaroger on 26/02/17. 5 | */ 6 | 7 | module.exports = require('require-dir')(); -------------------------------------------------------------------------------- /dist/utils/isPromise.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 4 | 5 | /** 6 | * Created by desaroger on 4/03/17. 7 | */ 8 | 9 | module.exports = function isPromise(x) { 10 | if (!x || (typeof x === 'undefined' ? 'undefined' : _typeof(x)) !== 'object') return false; 11 | if (typeof x.then !== 'function' || typeof x.catch !== 'function') return false; 12 | return true; 13 | }; -------------------------------------------------------------------------------- /dist/utils/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Created by desaroger on 26/02/17. 5 | */ 6 | 7 | function log(msg) { 8 | var status = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; 9 | 10 | log.count++; 11 | msg = '[' + log.getDate() + '] ' + msg; 12 | if (log.mocks.length) { 13 | var mock = log.mocks.shift(); 14 | if (mock.options.checkNow) { 15 | mock.method(msg, status); 16 | } else { 17 | log.history.push({ method: mock.method, msg: msg, status: status }); 18 | } 19 | // return method.call(log, msg, status); 20 | } else { 21 | return log.defaultMethod(msg, status); 22 | } 23 | } 24 | 25 | log.count = 0; 26 | log.history = []; 27 | 28 | log.getDate = function build() { 29 | return new Date().toISOString(); 30 | }; 31 | 32 | log.mocks = []; 33 | 34 | log.defaultMethod = function defaultMethod(msg) { 35 | var status = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; 36 | 37 | // if (process.env.NODE_ENV === 'test') { 38 | // // return; 39 | // } 40 | var map = { 41 | 0: 'log', 42 | 1: 'warn', 43 | 2: 'error' 44 | }; 45 | console[map[status]](msg); 46 | }; 47 | 48 | log.mock = function mock() { 49 | var method = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : function () {}; 50 | var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 51 | 52 | log.mocks.push({ method: method, options: options }); 53 | log.mockedMethod = method; 54 | }; 55 | 56 | log.restore = function restore() { 57 | log.count = 0; 58 | log.mockedOnce = false; 59 | log.mockedMethod = false; 60 | log.mocks = []; 61 | log.history = []; 62 | }; 63 | 64 | log.checkMocks = function checkMocks() { 65 | this.history.forEach(function (_ref) { 66 | var method = _ref.method, 67 | msg = _ref.msg, 68 | status = _ref.status; 69 | 70 | method(msg, status); 71 | }); 72 | }; 73 | 74 | module.exports = log; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pm2-hooks", 3 | "version": "1.1.14", 4 | "description": "Webhook server for pm2, compatible with any git repository", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir dist --copy-files", 8 | "lint": "eslint src test", 9 | "pretest": "npm run lint", 10 | "test": "NODE_ENV=test _mocha", 11 | "test:cover": "istanbul cover ./node_modules/.bin/_mocha" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/desaroger/pm2-hooks.git" 16 | }, 17 | "keywords": [ 18 | "pm2", 19 | "webhooks" 20 | ], 21 | "author": "Roger Fos Soler (desaroger23@gmail.com)", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/desaroger/pm2-hooks/issues" 25 | }, 26 | "homepage": "https://github.com/desaroger/pm2-hooks#readme", 27 | "dependencies": { 28 | "babel-regenerator-runtime": "^6.5.0", 29 | "body-parser": "^1.16.1", 30 | "co": "^4.6.0", 31 | "ipaddr.js": "^1.2.0", 32 | "lodash": "^4.17.10", 33 | "pm2": "^2.4.0", 34 | "pmx": "^1.0.3", 35 | "promisify-node": "^0.4.0", 36 | "require-dir": "^1.0.0" 37 | }, 38 | "devDependencies": { 39 | "babel": "^6.23.0", 40 | "babel-cli": "^6.23.0", 41 | "babel-preset-es2015": "^6.22.0", 42 | "chai": "^4.1.2", 43 | "chai-as-promised": "^7.1.1", 44 | "chai-shallow-deep-equal": "^1.4.4", 45 | "eslint": "^4.19.1", 46 | "eslint-config-airbnb": "^16.1.0", 47 | "eslint-plugin-import": "^2.2.0", 48 | "eslint-plugin-jsx-a11y": "^6.0.3", 49 | "eslint-plugin-react": "^7.8.2", 50 | "istanbul": "^0.4.5", 51 | "mocha": "^5.2.0", 52 | "mock-spawn": "^0.2.6", 53 | "request": "^2.79.0", 54 | "request-promise": "^4.1.1", 55 | "sinon": "^5.0.10", 56 | "url-join": "^4.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Pm2Module.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 25/02/17. 3 | */ 4 | 5 | let _ = require('lodash'); 6 | let childProcess = require('child_process'); 7 | let WebhookServer = require('./WebhookServer'); 8 | let { log } = require('./utils'); 9 | 10 | class Pm2Module { 11 | 12 | constructor(processes = [], options = {}) { 13 | options.routes = Pm2Module._parseProcesses(processes); 14 | this.routes = options.routes; 15 | this.webhookServer = new WebhookServer(options); 16 | } 17 | 18 | start() { 19 | return this.webhookServer.start() 20 | .then(() => { 21 | let msg = 'Started. Routes:\n'; 22 | _.forOwn(this.routes, (route, name) => { 23 | msg += ` - ${name}: ${JSON.stringify(route)}\n`; 24 | }); 25 | log(msg); 26 | }); 27 | } 28 | 29 | stop() { 30 | return this.webhookServer.stop() 31 | .then(() => { 32 | log('Stopped.'); 33 | }); 34 | } 35 | 36 | /** 37 | * Converts an array of PM2 processes to an object structured 38 | * for the WebhookServer routes. It internally uses the _parseProcess 39 | * method 40 | * 41 | * Example 1: 42 | * - input: 43 | * [ 44 | * { pm2_env: { env_hook: { name: 'api', type: 'bitbucket' } } }, 45 | * { pm2_env: { env_hook: { name: 'panel', type: 'github' } } } 46 | * ] 47 | * - output: 48 | * { 49 | * api: { type: 'bitbucket' }, 50 | * panel: { type: 'github' } 51 | * } 52 | * 53 | * @param processes 54 | * @returns {*} 55 | * @private 56 | */ 57 | static _parseProcesses(processes) { 58 | return processes 59 | .map(p => Pm2Module._parseProcess(p)) 60 | .filter(p => !!p) 61 | .reduce((routes, app) => { 62 | routes[app.name] = app; 63 | delete app.name; 64 | return routes; 65 | }, {}); 66 | } 67 | 68 | /** 69 | * Converts a PM2 process object to an object for WebhookServer 70 | * route. 71 | * 72 | * Example 1: 73 | * - input: { pm2_env: { env_hook: { name: 'api', type: 'bitbucket' } } } 74 | * - output: { name: 'api', type: 'bitbucket' } 75 | * Example 2: 76 | * - input: { pm2_env: { env_hook: { type: 'bitbucket' } } } 77 | * - output: { name: 'unknown', type: 'bitbucket' } 78 | * 79 | * @param app The Pm2 process 80 | * @returns {object|null} The route object, or null if invalid 81 | * @private 82 | */ 83 | static _parseProcess(app) { 84 | // Check data 85 | if (!app || !app.pm2_env) { 86 | return null; 87 | } 88 | let config = app.pm2_env.env_hook; 89 | if (!config) { 90 | log(`No options found for "${app.name}" route`); 91 | return null; 92 | } 93 | if (config === true) { 94 | config = {}; 95 | } 96 | 97 | // Config to WebhookServer route 98 | let self = this; 99 | let name = app.name || 'unknown'; 100 | let cwd = config.cwd || app.pm_cwd || app.pm2_env.cwd || app.pm2_env.pm_cwd; 101 | let commandOptions = Object.assign({}, { cwd }, config.commandOptions || {}); 102 | let route = { 103 | name, 104 | type: config.type, 105 | secret: config.secret, 106 | method(payload) { 107 | log(`Parsed payload: ${JSON.stringify(payload)}`); 108 | try { 109 | if (config.command) { 110 | log(`Running command: ${config.command}`); 111 | self._runCommand(config.command, commandOptions) 112 | .catch(e => onError(name, e)); 113 | } 114 | } catch (e) { 115 | onError(name, e); 116 | } 117 | } 118 | }; 119 | route = cleanObj(route); 120 | 121 | return route; 122 | 123 | function onError(routeName, e) { 124 | let err = e.message || e; 125 | log(`Error on "${name}" route: ${err}`, 2); 126 | throw e; 127 | } 128 | } 129 | 130 | /** 131 | * Runs a line command. 132 | * 133 | * @param {String} command The line to execute 134 | * @param {Object} options The object options 135 | * @returns {Promise} The code of the error, or a void fulfilled promise 136 | * @private 137 | */ 138 | static _runCommand(command, options = {}) { 139 | _.defaults(options, { 140 | env: process.env, 141 | shell: true 142 | }); 143 | return new Promise((resolve, reject) => { 144 | let child = childProcess.spawn('eval', [command], options); 145 | child.on('close', (code) => { 146 | if (!code) { 147 | resolve(); 148 | } else { 149 | reject(code); 150 | } 151 | }); 152 | }); 153 | } 154 | } 155 | 156 | module.exports = Pm2Module; 157 | 158 | function cleanObj(obj) { 159 | return _(obj).omitBy(_.isUndefined).value(); 160 | } 161 | -------------------------------------------------------------------------------- /src/WebhookServer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 23/02/17. 3 | */ 4 | 5 | const _ = require('lodash'); 6 | const http = require('http'); 7 | const bodyParser = require('body-parser'); 8 | const crypto = require('crypto'); 9 | // const ipaddr = require('ipaddr.js'); 10 | const { log, c, isPromise } = require('./utils'); 11 | 12 | class WebhookServer { 13 | 14 | /** 15 | * @param {object} options The options 16 | * @constructor 17 | */ 18 | constructor(options = {}) { 19 | _.defaults(options, { 20 | port: process.env.PORT || 9000, 21 | routes: {} 22 | }); 23 | for (let name in options.routes) { 24 | /* istanbul ignore next */ 25 | if (!options.routes.hasOwnProperty(name)) continue; 26 | options.routes[name].name = name; 27 | } 28 | this.options = options; 29 | } 30 | 31 | /** 32 | * Creates the server and start it (with .listen) 33 | * 34 | * @returns {Promise} Returns the instance 35 | */ 36 | start() { 37 | return new Promise((resolve, reject) => { 38 | if (this.server) { 39 | return reject('Server previously started'); 40 | } 41 | this.server = http.createServer(this._handleCall.bind(this)); 42 | this.server.listen(this.options.port, (err) => { 43 | /* istanbul ignore next */ 44 | if (err) return reject(err); 45 | resolve(this); 46 | }); 47 | }); 48 | } 49 | 50 | /** 51 | * Stops the server. Doesn't throws an error if fails. 52 | * 53 | * @returns {Promise} Returns the instance 54 | */ 55 | stop() { 56 | return new Promise((resolve) => { 57 | if (!this.server) { 58 | return resolve(this); 59 | } 60 | this.server.close(() => { 61 | delete this.server; 62 | resolve(this); 63 | }); 64 | }); 65 | } 66 | 67 | /** 68 | * Parses the body of a request 69 | * 70 | * @param {http.IncomingMessage} req The Request of the call 71 | * @param {http.ServerResponse} res The Response of the call 72 | * @param {function} fn The function to call after the body has been parsed 73 | * @private 74 | */ 75 | _parseBody(req, res, fn) { 76 | let rawBodyParser = (req2, res2, buf, encoding) => { 77 | if (buf && buf.length) { 78 | req2.rawBody = buf.toString(encoding || 'utf8'); 79 | console.log("rawBody", req2.rawBody); 80 | } else { 81 | console.log("nope"); 82 | } 83 | }; 84 | 85 | bodyParser.urlencoded({ 86 | extended: true, 87 | verify: rawBodyParser 88 | })(req, res, () => { 89 | bodyParser.json({ 90 | verify: rawBodyParser 91 | })(req, res, () => { 92 | bodyParser.raw({ 93 | type: () => true, 94 | verify: rawBodyParser 95 | })(req, res, fn); 96 | }); 97 | }); 98 | } 99 | 100 | /** 101 | * This method is the main function of the http server. 102 | * Given a request, it finds out the matched route and 103 | * calls the method of the route. 104 | * 105 | * @param {http.IncomingMessage} req The Request of the call 106 | * @param {http.ServerResponse} res The Response of the call 107 | * @private 108 | */ 109 | _handleCall(req, res) { 110 | let self = this; 111 | this._parseBody(req, res, c(function* () { 112 | try { 113 | // Mock 114 | let routeName = self._getRouteName(req); 115 | if (!routeName) { 116 | log('No route found on url', 1); 117 | res.statusCode = 400; 118 | res.end(JSON.stringify({ 119 | status: 'warning', 120 | message: 'No route found on url', 121 | code: 1 122 | })); 123 | return; 124 | } 125 | let route = self.options.routes[routeName]; 126 | if (!route) { 127 | log(`Warning: Route "${routeName}" not found`, 1); 128 | res.statusCode = 400; 129 | res.end(JSON.stringify({ 130 | status: 'warning', 131 | message: `Route "${routeName}" not found`, 132 | code: 1 133 | })); 134 | return; 135 | } 136 | 137 | // Prepare the execution of the method 138 | let payload = self._parseRequest(req, route); 139 | let result; 140 | try { 141 | result = route.method(payload); 142 | if (isPromise(result)) { 143 | result = yield result; 144 | } 145 | } catch (e) { 146 | let message = e.message ? e.message : e; 147 | log(`Error: Route "${routeName}" method error: ${message}`, 2); 148 | res.statusCode = 500; 149 | res.end(JSON.stringify({ 150 | status: 'error', 151 | message: `Route "${routeName}" method error: ${message}`, 152 | code: 2 153 | })); 154 | return; 155 | } 156 | 157 | log(`Success: Route "${routeName}" was found`, 0); 158 | res.end(JSON.stringify({ 159 | status: 'success', 160 | message: `Route "${routeName}" was found`, 161 | code: 0, 162 | result 163 | })); 164 | } catch (e) { 165 | log(e.message, 2); 166 | res.statusCode = 500; 167 | res.end(JSON.stringify({ 168 | status: 'error', 169 | message: `Unexpected error: ${e.message}`, 170 | code: 2 171 | })); 172 | } 173 | })); 174 | } 175 | 176 | /** 177 | * Given a request, returns the matched route 178 | * 179 | * @param {http.IncomingMessage} req The Request of the call 180 | * @returns {string|null} The matched route or null if not 181 | * @private 182 | */ 183 | _getRouteName(req) { 184 | let path = trimSlashes(req.url); 185 | let name = path.split('/')[0]; 186 | name = name.split('?')[0]; 187 | if (!name) { 188 | return null; 189 | } 190 | 191 | return name; 192 | } 193 | 194 | _parseRequest(req, route) { 195 | route = Object.assign({ 196 | name: '', 197 | type: 'auto', 198 | secret: false 199 | }, route); 200 | 201 | req.headers = _.transform(req.headers, (result, val, key) => { 202 | result[key.toLowerCase()] = val; 203 | }); 204 | 205 | // Auto-type 206 | if (route.type === 'auto') { 207 | if (req.headers['x-github-event']) { 208 | route.type = 'github'; 209 | } else if (req.headers['user-agent'] && /bitbucket/i.test(req.headers['user-agent'])) { 210 | route.type = 'bitbucket'; 211 | } 212 | if (route.type === 'auto') { 213 | route.type = false; 214 | } 215 | } 216 | 217 | // Checks 218 | let result = {}; 219 | if (route.type) { 220 | let checksMap = { 221 | github() { 222 | let error = false; 223 | if (!req.headers['x-github-event'] || (route.secret && !req.headers['x-hub-signature'])) { 224 | error = 'Invalid headers'; 225 | } else if (route.secret) { 226 | if (!req.rawBody) { 227 | error = 'Secret required and body not found on request'; 228 | } else { 229 | let hash = crypto.createHmac('sha1', route.secret); 230 | hash = hash.update(req.rawBody).digest('hex'); 231 | if (`sha1=${hash}` !== req.headers['x-hub-signature']) { 232 | error = 'Invalid secret'; 233 | } 234 | } 235 | } 236 | return error; 237 | }, 238 | bitbucket() { 239 | let error = false; 240 | let requiredHeaders = ['x-event-key', 'x-request-uuid']; 241 | if (requiredHeaders.some(k => !req.headers[k])) { 242 | error = 'Invalid headers'; 243 | } else if (route.secret) { 244 | error = 'Secret not supported for bitbucket yet'; 245 | } else { 246 | error = 'Invalid body'; 247 | if (req.body && _.isObjectLike(req.body)) { 248 | let action = req.headers['x-event-key'].replace('repo:', ''); 249 | if (!action) { 250 | error = 'Invalid headers'; 251 | } else if (req.body[action]) { 252 | error = false; 253 | } 254 | } 255 | } 256 | return error; 257 | }, 258 | test() {} 259 | }; 260 | let method = checksMap[route.type]; 261 | if (!method) { 262 | let error = `Error unknown route type ${route.type}`; 263 | log(error, 2); 264 | throw new Error(error); 265 | } 266 | 267 | let error = method(); 268 | if (error) { 269 | error = `Error for route type ${route.type}: ${error}`; 270 | log(error, 2); 271 | throw new Error(error); 272 | } 273 | 274 | // Parse 275 | let body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; 276 | body = body || {}; 277 | let parseMap = { 278 | github() { 279 | if (body.payload) { 280 | body = body.payload; 281 | } 282 | if (typeof body === 'string') { 283 | body = JSON.parse(body); 284 | } 285 | result.name = _.get(body, 'repository.name'); 286 | result.action = req.headers['x-github-event']; 287 | result.branch = _.get(body, 'ref', '').replace('refs/heads/', '') || false; 288 | }, 289 | bitbucket() { 290 | result.action = req.headers['x-event-key'].replace('repo:', ''); 291 | result.name = _.get(body, 'repository.name'); 292 | result.branch = ''; 293 | let changes = _.get(body, `${result.action}.changes`) || []; 294 | let validChange = changes 295 | .reduce((total, change) => { 296 | total = total.concat([change.old, change.new]); 297 | return total; 298 | }, []) 299 | .filter((change) => { 300 | return change && change.type === 'branch'; 301 | }) 302 | .find((change) => { 303 | return !!change.name; 304 | }); 305 | if (!validChange) { 306 | error = `Error for route type ${route.type}: Invalid "changes" key on body`; 307 | log(error, 2); 308 | throw new Error(error); 309 | } 310 | result.branch = validChange.name; 311 | }, 312 | test() { 313 | result = body; 314 | } 315 | }; 316 | parseMap[route.type](); 317 | } 318 | 319 | return result; 320 | } 321 | 322 | /** 323 | * Checks if the internal server is running. 324 | * 325 | * @returns {boolean} True if the server is running 326 | */ 327 | isRunning() { 328 | return !!(this.server && this.server.address()); 329 | } 330 | 331 | } 332 | 333 | module.exports = WebhookServer; 334 | 335 | function trimSlashes(s) { 336 | s = s.trim().replace(/(^\/)|(\/$)/, ''); 337 | return s; 338 | } 339 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 21/02/17. 3 | */ 4 | 5 | let pmx = require('pmx'); 6 | let pm2 = require('pm2'); 7 | let Pm2Module = require('./Pm2Module'); 8 | require('babel-regenerator-runtime'); 9 | 10 | pmx.initModule({}, (errPMX, conf) => { 11 | pm2.connect((errPM2) => { 12 | if (errPMX || errPM2) { 13 | console.error(errPMX || errPM2); // eslint-disable-line no-console 14 | return; 15 | } 16 | pm2.list((err, apps) => { 17 | let pm2Module = new Pm2Module(apps, conf); 18 | pm2Module.start(); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/c.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 23/02/17. 3 | */ 4 | 5 | let co = require('co'); 6 | 7 | let c = co.wrap; 8 | c.run = co; 9 | 10 | module.exports = c; 11 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 26/02/17. 3 | */ 4 | 5 | module.exports = require('require-dir')(); 6 | -------------------------------------------------------------------------------- /src/utils/isPromise.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 4/03/17. 3 | */ 4 | 5 | module.exports = function isPromise(x) { 6 | if (!x || typeof x !== 'object') return false; 7 | if (typeof x.then !== 'function' || typeof x.catch !== 'function') return false; 8 | return true; 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 26/02/17. 3 | */ 4 | 5 | function log(msg, status = 0) { 6 | log.count++; 7 | msg = `[${log.getDate()}] ${msg}`; 8 | if (log.mocks.length) { 9 | let mock = log.mocks.shift(); 10 | if (mock.options.checkNow) { 11 | mock.method(msg, status); 12 | } else { 13 | log.history.push({ method: mock.method, msg, status }); 14 | } 15 | // return method.call(log, msg, status); 16 | } else { 17 | return log.defaultMethod(msg, status); 18 | } 19 | } 20 | 21 | log.count = 0; 22 | log.history = []; 23 | 24 | log.getDate = function build() { 25 | return new Date().toISOString(); 26 | }; 27 | 28 | log.mocks = []; 29 | 30 | log.defaultMethod = function defaultMethod(msg, status = 0) { 31 | // if (process.env.NODE_ENV === 'test') { 32 | // // return; 33 | // } 34 | let map = { 35 | 0: 'log', 36 | 1: 'warn', 37 | 2: 'error' 38 | }; 39 | console[map[status]](msg); 40 | }; 41 | 42 | log.mock = function mock(method = () => {}, options = {}) { 43 | log.mocks.push({ method, options }); 44 | log.mockedMethod = method; 45 | }; 46 | 47 | log.restore = function restore() { 48 | log.count = 0; 49 | log.mockedOnce = false; 50 | log.mockedMethod = false; 51 | log.mocks = []; 52 | log.history = []; 53 | }; 54 | 55 | log.checkMocks = function checkMocks() { 56 | this.history.forEach(({ method, msg, status }) => { 57 | method(msg, status); 58 | }); 59 | }; 60 | 61 | module.exports = log; 62 | -------------------------------------------------------------------------------- /test/Pm2Module.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 25/02/17. 3 | */ 4 | 5 | let _ = require('lodash'); 6 | let Pm2Module = require('../src/Pm2Module'); 7 | let WebhookServer = require('../src/WebhookServer'); 8 | let mockApps = require('./mocks/apps'); 9 | let { expect, c, callApi, mockSpawn, log } = require('./assets'); 10 | 11 | describe('Pm2Module', () => { 12 | afterEach(() => { 13 | log.restore(); 14 | }); 15 | 16 | it('is a function', () => { 17 | expect(Pm2Module).to.be.a('function'); 18 | }); 19 | 20 | it('can be instantiated', () => { 21 | expect(() => new Pm2Module()).to.not.throw(); 22 | }); 23 | 24 | describe('instance', (pm2Module) => { 25 | before(() => { 26 | let apps = [ 27 | wrapEnv({ name: 'a' }, { type: 'github' }), 28 | wrapEnv({ name: 'b' }, { type: 'bitbucket' }), 29 | wrapEnv({ name: 'c' }, { type: 'gitlab' }), 30 | wrapEnv({ name: 'd' }, { }) 31 | ]; 32 | pm2Module = new Pm2Module(apps); 33 | }); 34 | 35 | it('has the method start', () => { 36 | expect(pm2Module.start).to.be.a('function'); 37 | }); 38 | 39 | it('has the method stop', () => { 40 | expect(pm2Module.stop).to.be.a('function'); 41 | }); 42 | 43 | it('has created the server, but not started', () => { 44 | expect(pm2Module.webhookServer).to.be.an.instanceOf(WebhookServer); 45 | expect(pm2Module.webhookServer.isRunning()).to.equal(false); 46 | }); 47 | 48 | it('the server has the routes created', () => { 49 | let routes = pm2Module.webhookServer.options.routes; 50 | expect(routes.a).to.shallowDeepEqual({ type: 'github' }); 51 | expect(routes.b).to.shallowDeepEqual({ type: 'bitbucket' }); 52 | expect(routes.c).to.shallowDeepEqual({ type: 'gitlab' }); 53 | expect(routes.d).to.be.an('object'); 54 | expect(routes.d.type).to.not.be.ok; 55 | }); 56 | }); 57 | 58 | describe('[run commands]', (pm2Module) => { 59 | before(() => { 60 | let apps = [ 61 | wrapEnv({ 62 | name: 'a' 63 | }, { 64 | command: 'echo hi', 65 | cwd: '/home/desaroger' 66 | }), 67 | wrapEnv({ 68 | name: 'b', 69 | pm_cwd: '/home/lol' 70 | }, { 71 | command: 'echo-nope hi' 72 | }), 73 | wrapEnv({ 74 | name: 'c', 75 | pm_cwd: '/home/nope' 76 | }, { 77 | command: 'echo yeah', 78 | cwd: '/home/yeah' 79 | }), 80 | wrapEnv({ 81 | name: 'd', 82 | pm_cwd: '/home/nope' 83 | }, {}) 84 | ]; 85 | pm2Module = new Pm2Module(apps, { 86 | port: 1234 87 | }); 88 | return pm2Module.start(); 89 | }); 90 | after(() => pm2Module.stop()); 91 | afterEach(() => mockSpawn.restore()); 92 | 93 | it('is running', () => { 94 | expect(pm2Module.webhookServer.isRunning()).to.equal(true); 95 | }); 96 | 97 | it('returns the warning if not found', c(function* () { 98 | log.mock((msg, status) => { 99 | expect(msg).to.match(/route "nope" not found/i); 100 | expect(status).to.equal(1); 101 | }); 102 | let result = yield callApi('/nope'); 103 | expect(result).to.deep.equal({ 104 | $statusCode: 400, 105 | status: 'warning', 106 | message: 'Route "nope" not found', 107 | code: 1 108 | }); 109 | expect(log.count).to.equal(1); 110 | log.checkMocks(); 111 | })); 112 | 113 | it('runs the command', c(function* () { 114 | mockSpawn.start((command, options) => { 115 | expect(command).to.equal('echo hi'); 116 | }); 117 | log.mock((msg, status) => { 118 | expect(status).to.equal(0); 119 | expect(msg).to.match(/Parsed payload: \{}/); 120 | }); 121 | log.mock((msg, status) => { 122 | expect(msg).to.match(/running command: echo hi/i); 123 | expect(status).to.equal(0); 124 | }); 125 | log.mock((msg, status) => { 126 | expect(msg).to.match(/route "a" was found/i); 127 | expect(status).to.equal(0); 128 | }); 129 | let result = yield callApi('/a'); 130 | expect(result).to.deep.equal({ 131 | $statusCode: 200, 132 | status: 'success', 133 | message: 'Route "a" was found', 134 | code: 0 135 | }); 136 | expect(log.count).to.equal(3); 137 | log.checkMocks(); 138 | })); 139 | 140 | it('doesn\'t throw if no command', c(function* () { 141 | log.mock((msg, status) => { 142 | expect(status).to.equal(0); 143 | expect(msg).to.match(/Parsed payload: \{}/); 144 | }); 145 | log.mock((msg, status) => { 146 | expect(msg).to.match(/route "d" was found/i); 147 | expect(status).to.equal(0); 148 | }); 149 | let result = yield callApi('/d'); 150 | expect(result).to.deep.equal({ 151 | $statusCode: 200, 152 | status: 'success', 153 | message: 'Route "d" was found', 154 | code: 0 155 | }); 156 | expect(log.count).to.equal(2); 157 | log.checkMocks(); 158 | })); 159 | 160 | it('runs the command in the CWD if available on config', c(function* () { 161 | mockSpawn.start((command, options) => { 162 | expect(options).to.be.an('object'); 163 | expect(options.cwd).to.equal('/home/desaroger'); 164 | expect(command).to.equal('echo hi'); 165 | }); 166 | log.mock((msg, status) => { 167 | expect(status).to.equal(0); 168 | expect(msg).to.match(/Parsed payload: \{}/); 169 | }); 170 | log.mock((msg, status) => { 171 | expect(status).to.equal(0); 172 | expect(msg).to.match(/Running command: echo hi/); 173 | }); 174 | log.mock((msg, status) => { 175 | expect(status).to.equal(0); 176 | expect(msg).to.match(/Success: Route "a" was found/); 177 | }); 178 | let result = yield expect(callApi('/a')).to.be.fulfilled; 179 | expect(result).to.deep.equal({ 180 | $statusCode: 200, 181 | status: 'success', 182 | message: 'Route "a" was found', 183 | code: 0 184 | }); 185 | log.checkMocks(); 186 | })); 187 | 188 | it('runs the command in the CWD with the app cwd if no present config', c(function* () { 189 | mockSpawn.start((command, options) => { 190 | expect(options).to.be.an('object'); 191 | expect(options.cwd).to.equal('/home/lol'); 192 | expect(command).to.equal('echo-nope hi'); 193 | }); 194 | log.mock((msg, status) => { 195 | expect(status).to.equal(0); 196 | expect(msg).to.match(/Parsed payload: \{}/); 197 | }); 198 | log.mock((msg, status) => { 199 | expect(status).to.equal(0); 200 | expect(msg).to.match(/Running command: echo-nope hi/); 201 | }); 202 | log.mock((msg, status) => { 203 | expect(status).to.equal(0); 204 | expect(msg).to.match(/Success: Route "b" was found/); 205 | }); 206 | let result = yield expect(callApi('/b')).to.be.fulfilled; 207 | expect(result).to.deep.equal({ 208 | $statusCode: 200, 209 | status: 'success', 210 | message: 'Route "b" was found', 211 | code: 0 212 | }); 213 | log.checkMocks(); 214 | })); 215 | 216 | it('runs the command in the CWD with priority to the config', c(function* () { 217 | mockSpawn.start((command, options) => { 218 | expect(options).to.be.an('object'); 219 | expect(options.cwd).to.equal('/home/yeah'); 220 | expect(command).to.equal('echo yeah'); 221 | }); 222 | log.mock((msg, status) => { 223 | expect(status).to.equal(0); 224 | expect(msg).to.match(/Parsed payload: \{}/); 225 | }); 226 | log.mock((msg, status) => { 227 | expect(status).to.equal(0); 228 | expect(msg).to.match(/Running command: echo yeah/); 229 | }); 230 | log.mock((msg, status) => { 231 | expect(status).to.equal(0); 232 | expect(msg).to.match(/Success: Route "c" was found/); 233 | }); 234 | let result = yield expect(callApi('/c')).to.be.fulfilled; 235 | expect(result).to.deep.equal({ 236 | $statusCode: 200, 237 | status: 'success', 238 | message: 'Route "c" was found', 239 | code: 0 240 | }); 241 | log.checkMocks(); 242 | })); 243 | 244 | it('shows error if command error', c(function* () { 245 | mockSpawn.start(() => { 246 | return function (cb) { 247 | this.emit('close', 'asd'); 248 | return cb(2); 249 | }; 250 | }); 251 | log.mock((msg, status) => { 252 | expect(status).to.equal(0); 253 | expect(msg).to.match(/Parsed payload: \{}/); 254 | }); 255 | log.mock((msg, status) => { 256 | expect(status).to.equal(0); 257 | expect(msg).to.match(/Running command: echo hi/); 258 | }); 259 | log.mock((msg, status) => { 260 | expect(status).to.equal(0); 261 | expect(msg).to.match(/Route "a" was found/); 262 | }); 263 | log.mock((msg, status) => { 264 | expect(status).to.equal(2); 265 | expect(msg).to.match(/Error on "a" route: asd/); 266 | }); 267 | let result = yield callApi('/a'); 268 | expect(result).to.deep.equal({ 269 | $statusCode: 200, 270 | status: 'success', 271 | message: 'Route "a" was found', 272 | code: 0 273 | }); 274 | log.checkMocks(); 275 | expect(log.count).to.equal(4); 276 | })); 277 | }); 278 | 279 | describe('method _parseProcesses', () => { 280 | it('exists', () => { 281 | expect(Pm2Module._parseProcesses).to.be.a('function'); 282 | }); 283 | 284 | it('accepts a void array', () => { 285 | let result = Pm2Module._parseProcesses([]); 286 | expect(result).to.deep.equal({}); 287 | }); 288 | 289 | it('sets "unknown" as default name', () => { 290 | let result = Pm2Module._parseProcesses([wrapEnv()]); 291 | expect(Object.keys(result)).to.have.length(1); 292 | expect(result.unknown).to.be.ok; 293 | }); 294 | 295 | it('works with a real processes array', () => { 296 | let result = Pm2Module._parseProcesses(mockApps); 297 | expect(result).to.have.all.keys(['api', 'api2', 'panel']); 298 | expect(result).to.shallowDeepEqual({ 299 | api: { 300 | type: 'bitbucket' 301 | }, 302 | api2: {}, 303 | panel: { 304 | type: 'gitlab' 305 | } 306 | }); 307 | }); 308 | }); 309 | 310 | describe('method _parseProcess', () => { 311 | it('exists', () => { 312 | expect(Pm2Module._parseProcess).to.be.a('function'); 313 | }); 314 | 315 | it('returns false if no options specified', () => { 316 | let objs = { 317 | a: undefined, 318 | b: null, 319 | c: {}, 320 | d: { pm2_env: {} } 321 | }; 322 | let mockLog = (msg, status) => { 323 | expect(status).to.equal(0); 324 | expect(msg).to.match(/No options found for "undefined" route/); 325 | }; 326 | log.mock(mockLog); 327 | 328 | _.values(objs) 329 | .forEach((opts) => { 330 | expect(Pm2Module._parseProcess(opts)).to.equal(null); 331 | }); 332 | expect(log.count).to.equal(1); 333 | log.checkMocks(); 334 | }); 335 | 336 | it('returns the route if valid object', () => { 337 | let obj = wrapEnv({ name: 'lol' }, { type: 'bitbucket' }); 338 | let result = Pm2Module._parseProcess(obj); 339 | expect(result).to.shallowDeepEqual({ 340 | name: 'lol', 341 | type: 'bitbucket' 342 | }); 343 | }); 344 | 345 | it('returns the route if equal true', () => { 346 | let obj = wrapEnv({ name: 'lol' }, true); 347 | let result = Pm2Module._parseProcess(obj); 348 | expect(result).to.shallowDeepEqual({ 349 | name: 'lol' 350 | }); 351 | }); 352 | }); 353 | 354 | describe('method _runCommand', () => { 355 | it('exists', () => { 356 | expect(Pm2Module._runCommand).to.be.a('function'); 357 | }); 358 | 359 | it('returns a promise', () => { 360 | let result = Pm2Module._runCommand('echo hi'); 361 | expect(result).to.be.ok; 362 | expect(result.then).to.be.a('function'); 363 | expect(result.catch).to.be.a('function'); 364 | }); 365 | 366 | it('runs the line', () => { 367 | return Pm2Module._runCommand('echo hi') 368 | .then((result) => { 369 | expect(result).to.not.be.ok; 370 | }) 371 | .catch((err) => { 372 | expect(err).to.not.be.ok; 373 | }); 374 | }); 375 | 376 | it('throws an error if fails', () => { 377 | return Pm2Module._runCommand('765-this-need-to-not-exists-231 hi') 378 | .catch((err) => { 379 | expect(err).to.be.ok; 380 | }); 381 | }); 382 | }); 383 | }); 384 | 385 | /** 386 | * Creates an object with the same structure as the PM2 process 387 | * Example structure: 388 | * { 389 | * name: 'api', 390 | * pm2_env: { 391 | * env: {}, 392 | * env_hooks: { 393 | * command: 'echo hi' 394 | * } 395 | * } 396 | * } 397 | * 398 | * @param {object} appData The root object 399 | * @param {object} envHook The object inside 'pm2_env.env_hooks' path 400 | * @returns {object} The built object 401 | */ 402 | function wrapEnv(appData = {}, envHook = {}) { 403 | appData.pm2_env = { env_hook: envHook }; 404 | return appData; 405 | } 406 | -------------------------------------------------------------------------------- /test/WebhookServer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 23/02/17. 3 | */ 4 | /* eslint-disable global-require */ 5 | 6 | let _ = require('lodash'); 7 | let sinon = require('sinon'); 8 | let { expect, c, callApi, log } = require('./assets'); 9 | let WebhookServer = require('../src/WebhookServer'); 10 | 11 | let mocks = getMocks(); 12 | 13 | describe('webhookServer', () => { 14 | beforeEach(() => { 15 | log.restore(); 16 | }); 17 | 18 | it('is a function', () => { 19 | expect(WebhookServer).to.be.a('function'); 20 | }); 21 | 22 | it('can be instantiated', () => { 23 | expect(() => new WebhookServer()).to.not.throw(); 24 | }); 25 | 26 | describe('the instance', (whs, calls = 0) => { 27 | before(() => { 28 | whs = new WebhookServer({ 29 | port: 1234, 30 | routes: { 31 | demo: { 32 | type: 'github', 33 | method() { 34 | calls++; 35 | } 36 | } 37 | } 38 | }); 39 | }); 40 | afterEach(() => whs.stop()); 41 | 42 | it('not has the server created', () => { 43 | expect(whs.server).to.not.be.ok; 44 | }); 45 | 46 | it('has the expected methods', () => { 47 | expect(whs.isRunning).to.be.a('function'); 48 | expect(whs.start).to.be.a('function'); 49 | expect(whs.stop).to.be.a('function'); 50 | }); 51 | 52 | it('is not running initially', () => { 53 | expect(whs.isRunning()).to.equal(false); 54 | }); 55 | 56 | it('can be started', () => { 57 | return expect(whs.start()).to.eventually.be.fulfilled; 58 | }); 59 | 60 | it('shows as running', () => { 61 | return whs.stop() 62 | .then(() => whs.start()) 63 | .then(() => { 64 | expect(whs.isRunning()).to.equal(true); 65 | }); 66 | }); 67 | }); 68 | 69 | describe('call handler', (whs, calls = 0, method) => { 70 | before(() => { 71 | whs = new WebhookServer({ 72 | port: 1234, 73 | routes: { 74 | working: { 75 | method() { calls++; } 76 | }, 77 | withoutType: { 78 | method() { calls++; } 79 | }, 80 | throwsError: { 81 | method() { 82 | throw new Error('Test error'); 83 | } 84 | }, 85 | throwsString: { 86 | method() { 87 | throw 'Test throw string'; // eslint-disable-line no-throw-literal 88 | } 89 | }, 90 | checkPayload: { 91 | type: 'test', 92 | method(...args) { 93 | return method.call(this, ...args); 94 | } 95 | } 96 | } 97 | }); 98 | }); 99 | afterEach(() => { 100 | calls = 0; 101 | return whs.stop(); 102 | }); 103 | 104 | it('the server is actually listening', c(function* () { 105 | let spy = sinon.spy(whs, '_handleCall'); 106 | yield whs.start(); 107 | log.restore(); 108 | 109 | // Logs 110 | log.mock((msg, status) => { 111 | expect(msg).to.match(/no route found on url/i); 112 | expect(status).to.equal(1); 113 | }); 114 | yield callApi(); 115 | log.mock((msg, status) => { 116 | expect(msg).to.match(/route "asd" not found/i); 117 | expect(status).to.equal(1); 118 | }); 119 | yield callApi('/asd'); 120 | 121 | expect(spy.calledTwice).to.equal(true); 122 | spy.restore(); 123 | expect(log.count).to.equal(2); 124 | log.checkMocks(); 125 | })); 126 | 127 | it('returns success if the route works', c(function* () { 128 | expect(calls).to.equal(0); 129 | yield whs.start(); 130 | log.mock((msg, status) => { 131 | expect(msg).to.match(/route "working" was found/i); 132 | expect(status).to.equal(0); 133 | }); 134 | let body = yield callApi('/working'); 135 | expect(body).to.be.deep.equal({ 136 | $statusCode: 200, 137 | status: 'success', 138 | message: 'Route "working" was found', 139 | code: 0 140 | }); 141 | expect(calls).to.equal(1); 142 | expect(log.count).to.equal(1); 143 | log.checkMocks(); 144 | })); 145 | 146 | it('works even though there is no type', c(function* () { 147 | expect(calls).to.equal(0); 148 | yield whs.start(); 149 | log.mock((msg, status) => { 150 | expect(msg).to.match(/route "withoutType" was found/i); 151 | expect(status).to.equal(0); 152 | }); 153 | let body = yield callApi('/withoutType'); 154 | expect(body).to.be.deep.equal({ 155 | $statusCode: 200, 156 | status: 'success', 157 | message: 'Route "withoutType" was found', 158 | code: 0 159 | }); 160 | expect(calls).to.equal(1); 161 | expect(log.count).to.equal(1); 162 | log.checkMocks(); 163 | })); 164 | 165 | it('returns a warning if called a non-existent route', c(function* () { 166 | expect(calls).to.equal(0); 167 | yield whs.start(); 168 | log.mock((msg, status) => { 169 | expect(msg).to.match(/route "lol" not found/i); 170 | expect(status).to.equal(1); 171 | }); 172 | let body = yield callApi('/lol'); 173 | expect(body).to.be.deep.equal({ 174 | $statusCode: 400, 175 | status: 'warning', 176 | message: 'Route "lol" not found', 177 | code: 1 178 | }); 179 | expect(calls).to.equal(0); 180 | log.checkMocks(); 181 | })); 182 | 183 | it('returns an error if the method triggers an error', c(function* () { 184 | expect(calls).to.equal(0); 185 | yield whs.start(); 186 | log.mock((msg, status) => { 187 | expect(msg).to.match(/route "throwsError" method error: Test error/i); 188 | expect(status).to.equal(2); 189 | }); 190 | let body = yield callApi('/throwsError'); 191 | expect(body).to.be.deep.equal({ 192 | $statusCode: 500, 193 | status: 'error', 194 | message: 'Route "throwsError" method error: Test error', 195 | code: 2 196 | }); 197 | expect(calls).to.equal(0); 198 | log.checkMocks(); 199 | })); 200 | 201 | it('returns an error if the method triggers a value', c(function* () { 202 | expect(calls).to.equal(0); 203 | yield whs.start(); 204 | log.mock((msg, status) => { 205 | expect(msg).to.match(/route "throwsString" method error: Test throw string/i); 206 | expect(status).to.equal(2); 207 | }); 208 | let body = yield callApi('/throwsString'); 209 | expect(body).to.be.deep.equal({ 210 | $statusCode: 500, 211 | status: 'error', 212 | message: 'Route "throwsString" method error: Test throw string', 213 | code: 2 214 | }); 215 | expect(calls).to.equal(0); 216 | log.checkMocks(); 217 | })); 218 | 219 | it('pass the payload to the method', c(function* () { 220 | expect(calls).to.equal(0); 221 | yield whs.start(); 222 | method = (payload) => { 223 | expect(payload).to.deep.equal({ 224 | name: 'pm2-hooks', 225 | action: 'push', 226 | branch: 'master' 227 | }); 228 | }; 229 | log.mock((msg, status) => { 230 | expect(msg).to.match(/route "checkPayload" was found/i); 231 | expect(status).to.equal(0); 232 | }); 233 | let body = yield callApi( 234 | '/checkPayload', 235 | { 236 | name: 'pm2-hooks', 237 | action: 'push', 238 | branch: 'master' 239 | } 240 | ); 241 | expect(body).to.be.deep.equal({ 242 | $statusCode: 200, 243 | status: 'success', 244 | message: 'Route "checkPayload" was found', 245 | code: 0 246 | }); 247 | expect(calls).to.equal(0); 248 | log.checkMocks(); 249 | })); 250 | 251 | it('handles unexpected errors', c(function* () { 252 | expect(calls).to.equal(0); 253 | yield whs.start(); 254 | 255 | log.mock((msg, status) => { 256 | throw new Error('SuperError'); 257 | }, { checkNow: true }); 258 | log.mock((msg, status) => { 259 | expect(status).to.equal(2); 260 | expect(msg).to.match(/SuperError/); 261 | }); 262 | let body = yield callApi( 263 | '/working', 264 | { 265 | name: 'pm2-hooks', 266 | action: 'push', 267 | branch: 'master' 268 | } 269 | ); 270 | expect(body).to.be.deep.equal({ 271 | $statusCode: 500, 272 | status: 'error', 273 | message: 'Unexpected error: SuperError', 274 | code: 2 275 | }); 276 | expect(calls).to.equal(1); 277 | log.checkMocks(); 278 | })); 279 | 280 | it('throws an error if server previously listening', c(function* () { 281 | yield expect(whs.start()).to.be.fulfilled; 282 | yield expect(whs.start()).to.be.rejectedWith('Server previously started'); 283 | })); 284 | }); 285 | 286 | describe('_parseRequest', () => { 287 | let whs; 288 | before(() => { 289 | whs = new WebhookServer({ 290 | port: 1234, 291 | routes: { 292 | demo: { 293 | method() {} 294 | } 295 | } 296 | }); 297 | sinon.spy(whs.options.routes.demo, 'method'); 298 | }); 299 | 300 | it('exists', () => { 301 | expect(whs._parseRequest).to.be.a('function'); 302 | }); 303 | 304 | it('logs on error', () => { 305 | let route = { 306 | type: 'github' 307 | }; 308 | let req = { 309 | headers: {} 310 | }; 311 | log.mock((msg, status) => { 312 | expect(msg).to.match(/Error for route type github: Invalid headers/i); 313 | expect(status).to.equal(2); 314 | }); 315 | expect(() => whs._parseRequest(req, route)) 316 | .to.throw(/Error for route type github: Invalid headers/); 317 | expect(log.count).to.equal(1); 318 | log.checkMocks(); 319 | }); 320 | 321 | it('throws if unknown type', () => { 322 | let route = { 323 | type: 'nope' 324 | }; 325 | let req = { 326 | headers: {} 327 | }; 328 | log.mock((msg, status) => { 329 | expect(msg).to.match(/Error unknown route type nope/i); 330 | expect(status).to.equal(2); 331 | }); 332 | expect(() => whs._parseRequest(req, route)) 333 | .to.throw(/Error unknown route type nope/); 334 | expect(log.count).to.equal(1); 335 | log.checkMocks(); 336 | }); 337 | 338 | describe('[github]', () => { 339 | it('checks the headers', () => { 340 | let route = { 341 | type: 'github' 342 | }; 343 | let req = { 344 | headers: {} 345 | }; 346 | log.mock((msg, status) => { 347 | expect(status).to.equal(2); 348 | expect(msg).to.match(/Error for route type github: Invalid headers/); 349 | }); 350 | expect(() => whs._parseRequest(req, route)) 351 | .to.throw(/Error for route type github: Invalid headers/); 352 | log.checkMocks(); 353 | }); 354 | 355 | it('doesn\'t throw if headers found', () => { 356 | let route = {}; 357 | let req = { 358 | headers: { 359 | 'x-github-event': 'push', 360 | 'x-hub-signature': 'sha1=asdadsa' 361 | } 362 | }; 363 | expect(() => whs._parseRequest(req, route)).to.not.throw(); 364 | }); 365 | 366 | it('checks if secret fails', () => { 367 | let route = { 368 | secret: 'lol' 369 | }; 370 | let req = { 371 | headers: { 372 | 'x-github-event': 'push', 373 | 'x-hub-signature': 'sha1=nopenopenope' 374 | }, 375 | rawBody: 'superbody' 376 | }; 377 | log.mock((msg, status) => { 378 | expect(status).to.equal(2); 379 | expect(msg).to.match(/Error for route type github: Invalid secret/); 380 | }); 381 | expect(() => whs._parseRequest(req, route)) 382 | .to.throw(/Error for route type github: Invalid secret/); 383 | log.checkMocks(); 384 | }); 385 | 386 | it('checks if secret works', () => { 387 | let route = { 388 | secret: 'lol' 389 | }; 390 | let req = { 391 | headers: { 392 | 'x-github-event': 'push', 393 | 'x-hub-signature': 'sha1=241946ca6d19a74a9e52ea4b6a59ceb9c5cf309f' 394 | }, 395 | rawBody: '{"lol":"yeah"}' 396 | }; 397 | expect(() => whs._parseRequest(req, route)).to.not.throw(); 398 | }); 399 | 400 | it('returns the action', () => { 401 | let route = {}; 402 | let req = { 403 | headers: { 404 | 'x-github-event': 'push', 405 | 'x-hub-signature': 'sha1=241949ceb9c5cf309f' 406 | }, 407 | body: '{"lol":"yeah"}' 408 | }; 409 | let result = whs._parseRequest(req, route); 410 | expect(result.action).to.equal('push'); 411 | }); 412 | 413 | it('returns the repository name', () => { 414 | let route = {}; 415 | let req = { 416 | headers: { 417 | 'x-github-event': 'push', 418 | 'x-hub-signature': 'sha1=241949ceb9c5cf309f' 419 | }, 420 | body: JSON.stringify({ 421 | repository: { 422 | name: 'pm2-hooks' 423 | } 424 | }) 425 | }; 426 | let result = whs._parseRequest(req, route); 427 | expect(result.name).to.equal('pm2-hooks'); 428 | }); 429 | 430 | it('returns the branch name', () => { 431 | let route = {}; 432 | let req = { 433 | headers: { 434 | 'x-github-event': 'push', 435 | 'x-hub-signature': 'sha1=241949ceb9c5cf309f' 436 | }, 437 | body: JSON.stringify({ 438 | ref: 'refs/heads/develop', 439 | repository: { 440 | name: 'pm2-hooks' 441 | } 442 | }) 443 | }; 444 | let result = whs._parseRequest(req, route); 445 | expect(result.branch).to.equal('develop'); 446 | }); 447 | 448 | it('works with a payload key', () => { 449 | let route = {}; 450 | let req = { 451 | headers: { 452 | 'x-github-event': 'push', 453 | 'x-hub-signature': 'sha1=241949ceb9c5cf309f' 454 | }, 455 | body: { 456 | payload: JSON.stringify({ 457 | ref: 'refs/heads/develop', 458 | repository: { 459 | name: 'pm2-hooks' 460 | } 461 | }) 462 | } 463 | }; 464 | let result = whs._parseRequest(req, route); 465 | expect(result.branch).to.equal('develop'); 466 | }); 467 | 468 | it('real example', () => { 469 | let mock = mocks.github.push; 470 | let route = {}; 471 | let req = { 472 | headers: mock.headers, 473 | body: JSON.stringify(mock.body) 474 | }; 475 | let result = whs._parseRequest(req, route); 476 | expect(result).to.deep.equal({ 477 | action: 'push', 478 | branch: 'changes', 479 | name: 'public-repo' 480 | }); 481 | }); 482 | }); 483 | 484 | describe('[bitbucket]', () => { 485 | it('checks the headers', () => { 486 | let route = { 487 | type: 'bitbucket' 488 | }; 489 | let req = { 490 | headers: {} 491 | }; 492 | log.mock((msg, status) => { 493 | expect(status).to.equal(2); 494 | expect(msg).to.match(/Error for route type bitbucket: Invalid headers/); 495 | }); 496 | expect(() => whs._parseRequest(req, route)) 497 | .to.throw(/Error for route type bitbucket: Invalid headers/); 498 | log.checkMocks(); 499 | }); 500 | 501 | it('throw if no body found', () => { 502 | let route = {}; 503 | let req = { 504 | headers: mocks.bitbucket.push.headers, 505 | body: {} 506 | }; 507 | log.mock((msg, status) => { 508 | expect(status).to.equal(2); 509 | expect(msg).to.match(/Error for route type bitbucket: Invalid body/); 510 | }); 511 | expect(() => whs._parseRequest(req, route)) 512 | .to.throw(/Error for route type bitbucket: Invalid body/); 513 | log.checkMocks(); 514 | }); 515 | 516 | it('throw if body isn\'t an object', () => { 517 | let route = {}; 518 | let req = { 519 | headers: mocks.bitbucket.push.headers, 520 | body: 'nope' 521 | }; 522 | log.mock((msg, status) => { 523 | expect(status).to.equal(2); 524 | expect(msg).to.match(/Error for route type bitbucket: Invalid body/); 525 | }); 526 | expect(() => whs._parseRequest(req, route)) 527 | .to.throw(/Error for route type bitbucket: Invalid body/); 528 | log.checkMocks(); 529 | }); 530 | 531 | it('throw if uncapable of find the action', () => { 532 | let route = {}; 533 | let req = { 534 | headers: _.clone(mocks.bitbucket.push.headers), 535 | body: {} 536 | }; 537 | req.headers['X-Event-Key'] = 'repo:'; 538 | log.mock((msg, status) => { 539 | expect(status).to.equal(2); 540 | expect(msg).to.match(/Error for route type bitbucket: Invalid headers/); 541 | }); 542 | expect(() => whs._parseRequest(req, route)) 543 | .to.throw(/Error for route type bitbucket: Invalid headers/); 544 | log.checkMocks(); 545 | }); 546 | 547 | it('throw if invalid "changes" key found', () => { 548 | let route = {}; 549 | let req = { 550 | headers: mocks.bitbucket.push.headers, 551 | body: { 552 | push: {} 553 | } 554 | }; 555 | log.mock((msg, status) => { 556 | expect(status).to.equal(2); 557 | expect(msg).to.match(/Error for route type bitbucket: Invalid "changes" key on body/); 558 | }); 559 | expect(() => whs._parseRequest(req, route)) 560 | .to.throw(/Error for route type bitbucket: Invalid "changes" key on body/); 561 | log.checkMocks(); 562 | }); 563 | 564 | it('throws when there is a secret, as is not supported yet', () => { 565 | let route = { 566 | type: 'bitbucket', 567 | secret: 'lol' 568 | }; 569 | let req = { 570 | headers: mocks.bitbucket.push.headers, 571 | body: {} 572 | }; 573 | log.mock((msg, status) => { 574 | expect(status).to.equal(2); 575 | expect(msg).to.match(/Error for route type bitbucket: Secret not supported for bitbucket yet/); 576 | }); 577 | expect(() => whs._parseRequest(req, route)) 578 | .to.throw(/Error for route type bitbucket: Secret not supported for bitbucket yet/); 579 | log.checkMocks(); 580 | }); 581 | 582 | it('returns the action', () => { 583 | let route = {}; 584 | let req = mocks.bitbucket.push; 585 | let result = whs._parseRequest(req, route); 586 | expect(result.action).to.equal('push'); 587 | }); 588 | 589 | it('returns the repository name', () => { 590 | let route = {}; 591 | let req = mocks.bitbucket.push; 592 | let result = whs._parseRequest(req, route); 593 | expect(result.name).to.equal('pm2-hooks'); 594 | }); 595 | 596 | it('returns the branch name', () => { 597 | let route = {}; 598 | let req = mocks.bitbucket.push; 599 | let result = whs._parseRequest(req, route); 600 | expect(result.branch).to.equal('master'); 601 | }); 602 | }); 603 | }); 604 | 605 | describe('_getRouteName', () => { 606 | let whs, mockReq; 607 | before(() => { 608 | whs = new WebhookServer({ 609 | port: 1234, 610 | routes: { 611 | demo: { 612 | method() {} 613 | } 614 | } 615 | }); 616 | sinon.spy(whs.options.routes.demo, 'method'); 617 | mockReq = (url) => { 618 | return { 619 | url 620 | }; 621 | }; 622 | }); 623 | 624 | it('returns null if there is no url', () => { 625 | let req = mockReq(''); 626 | expect(whs._getRouteName(req)).to.equal(null); 627 | req = mockReq('/'); 628 | expect(whs._getRouteName(req)).to.equal(null); 629 | req = mockReq(' // '); 630 | expect(whs._getRouteName(req)).to.equal(null); 631 | }); 632 | 633 | it('returns name despite it don\'t match', () => { 634 | let req = mockReq('/nope'); 635 | expect(whs._getRouteName(req)).to.equal('nope'); 636 | req = mockReq('/nope/nope'); 637 | expect(whs._getRouteName(req)).to.equal('nope'); 638 | }); 639 | 640 | it('returns the match if matched', () => { 641 | let req = mockReq('/demo'); 642 | expect(whs._getRouteName(req)).to.equal('demo'); 643 | req = mockReq('/demo?asd=2'); 644 | expect(whs._getRouteName(req)).to.equal('demo'); 645 | }); 646 | }); 647 | }); 648 | 649 | function getMocks() { 650 | return { 651 | bitbucket: { 652 | push: require('./mocks/bitbucket_push.json') 653 | }, 654 | github: { 655 | push: require('./mocks/github_push.json') 656 | } 657 | }; 658 | } 659 | -------------------------------------------------------------------------------- /test/assets/c.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 23/02/17. 3 | */ 4 | 5 | let co = require('co'); 6 | 7 | let c = co.wrap; 8 | c.run = co; 9 | 10 | module.exports = c; 11 | -------------------------------------------------------------------------------- /test/assets/callApi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 26/02/17. 3 | */ 4 | 5 | let _ = require('lodash'); 6 | let r = require('request-promise'); 7 | let urlJoin = require('url-join'); 8 | // let expect = require('./expect'); 9 | 10 | module.exports = (path, payload = false, options = {}) => { 11 | path = urlJoin('http://localhost:1234', path || '/'); 12 | _.defaults(options, { 13 | method: 'POST', 14 | uri: path, 15 | status: false, 16 | form: payload 17 | }); 18 | // if (payload) { 19 | // options.form = payload; 20 | // } 21 | // if (options.status) { 22 | // options.resolveWithFullResponse = true; 23 | // } 24 | return r(options) 25 | .then((body) => { 26 | if (typeof body === 'string') { 27 | body = JSON.parse(body); 28 | } 29 | body.$statusCode = 200; 30 | return body; 31 | }) 32 | .catch((e) => { 33 | let body = JSON.parse(e.response.body); 34 | body.$statusCode = e.response.statusCode; 35 | return body; 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /test/assets/chai.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 23/02/17. 3 | */ 4 | 5 | let chai = require('chai'); 6 | 7 | chai.use(require('chai-as-promised')); 8 | chai.use(require('chai-shallow-deep-equal')); 9 | 10 | module.exports = chai; 11 | -------------------------------------------------------------------------------- /test/assets/expect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 23/02/17. 3 | */ 4 | 5 | module.exports = require('./chai').expect; 6 | -------------------------------------------------------------------------------- /test/assets/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 23/02/17. 3 | */ 4 | 5 | let assets = require('../../src/utils'); 6 | Object.assign(assets, require('require-dir')()); 7 | 8 | module.exports = assets; 9 | -------------------------------------------------------------------------------- /test/assets/mockSpawn.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 26/02/17. 3 | */ 4 | 5 | let childProcess = require('child_process'); 6 | let mockSpawn = require('mock-spawn'); 7 | 8 | let originalSpawn = childProcess.spawn; 9 | 10 | module.exports = { 11 | history: [], 12 | buildSpawn(fn = false) { 13 | let mySpawn = mockSpawn(); 14 | if (fn) { 15 | mySpawn.setStrategy(function (command, args, options) { 16 | // self.history.push([fn, command, args, options]); 17 | return fn.call(this, args[0], options, { command, args }); 18 | }); 19 | } else { 20 | mySpawn.setDefault(mySpawn.simple(1 /* exit code */, 'hello world' /* stdout */)); 21 | } 22 | return mySpawn; 23 | }, 24 | start(fn = false) { 25 | let mySpawn = this.buildSpawn(fn); 26 | this.mySpawn = mySpawn; 27 | childProcess.spawn = mySpawn; 28 | }, 29 | restore() { 30 | childProcess.spawn = originalSpawn; 31 | } 32 | // check() { 33 | // this.history.forEach((history) => { 34 | // let method = history.shift(); 35 | // method(...history); 36 | // }); 37 | // } 38 | }; 39 | -------------------------------------------------------------------------------- /test/mocks/apps.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 26/02/17. 3 | */ 4 | 5 | module.exports = 6 | [ 7 | { 8 | name: 'panel', 9 | pm2_env: { 10 | env_hook: { 11 | type: 'gitlab' 12 | } 13 | }, 14 | pm_id: 0 15 | }, 16 | { 17 | name: 'api', 18 | pm2_env: { 19 | env_hook: { 20 | type: 'bitbucket' 21 | } 22 | }, 23 | pm_id: 1 24 | }, 25 | { 26 | name: 'api2', 27 | pm2_env: { 28 | env_hook: {} 29 | }, 30 | pm_id: 2 31 | }, 32 | { 33 | name: 'api3', 34 | pm_id: 3 35 | }, 36 | { 37 | pid: 7477, 38 | name: 'real', 39 | pm2_env: 40 | { 41 | exec_mode: 'fork_mode', 42 | env: [Object], 43 | treekill: true, 44 | autorestart: true, 45 | automation: true, 46 | pmx: true, 47 | vizion: true, 48 | cwd: '/home/desaroger/Runator/Projects/globalraces-api', 49 | name: 'api', 50 | node_args: [], 51 | pm_exec_path: '/home/desaroger/Runator/Projects/globalraces-api/server/server.js', 52 | pm_cwd: '/home/desaroger/Runator/Projects/globalraces-api', 53 | exec_interpreter: 'node', 54 | instances: 1, 55 | pm_out_log_path: '/home/desaroger/.pm2/logs/api-out-1.log', 56 | pm_err_log_path: '/home/desaroger/.pm2/logs/api-error-1.log', 57 | pm_pid_path: '/home/desaroger/.pm2/pids/api-1.pid', 58 | km_link: false, 59 | NODE_APP_INSTANCE: 0, 60 | vizion_running: false, 61 | api: '{}', 62 | PM2_HOME: '/home/desaroger/.pm2', 63 | COMMON_VARIABLE: 'true', 64 | XDG_VTNR: '7', 65 | LC_PAPER: 'es_ES.UTF-8', 66 | MANPATH: '/home/desaroger/.nvm/versions/node/v7.2.1/share/man:/usr/local/man:/usr/local/share/man:/usr/share/man', 67 | LC_ADDRESS: 'es_ES.UTF-8', 68 | XDG_SESSION_ID: 'c1', 69 | XDG_GREETER_DATA_DIR: '/var/lib/lightdm-data/desaroger', 70 | LC_MONETARY: 'es_ES.UTF-8', 71 | CLUTTER_IM_MODULE: 'xim', 72 | SESSION: 'ubuntu', 73 | NVM_CD_FLAGS: '', 74 | GPG_AGENT_INFO: '/home/desaroger/.gnupg/S.gpg-agent:0:1', 75 | TERM: 'xterm-256color', 76 | VTE_VERSION: '4205', 77 | SHELL: '/bin/bash', 78 | NVM_PATH: '/home/desaroger/.nvm/versions/node/v7.2.1/lib/node', 79 | QT_LINUX_ACCESSIBILITY_ALWAYS_ON: '1', 80 | WINDOWID: '79691786', 81 | LC_NUMERIC: 'es_ES.UTF-8', 82 | UPSTART_SESSION: 'unix:abstract=/com/ubuntu/upstart-session/1000/991', 83 | GNOME_KEYRING_CONTROL: '', 84 | GTK_MODULES: 'gail:atk-bridge:unity-gtk-module', 85 | NVM_DIR: '/home/desaroger/.nvm', 86 | USER: 'desaroger', 87 | LS_COLORS: 'rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.jpg=01;35:*.jpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:', 88 | LC_TELEPHONE: 'es_ES.UTF-8', 89 | QT_ACCESSIBILITY: '1', 90 | XDG_SESSION_PATH: '/org/freedesktop/DisplayManager/Session0', 91 | XDG_SEAT_PATH: '/org/freedesktop/DisplayManager/Seat0', 92 | SSH_AUTH_SOCK: '/run/user/1000/keyring/ssh', 93 | DEFAULTS_PATH: '/usr/share/gconf/ubuntu.default.path', 94 | XDG_CONFIG_DIRS: '/etc/xdg/xdg-ubuntu:/usr/share/upstart/xdg:/etc/xdg', 95 | DESKTOP_SESSION: 'ubuntu', 96 | PATH: '/home/desaroger/.nvm/versions/node/v7.2.1/bin:/home/desaroger/bin:/home/desaroger/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin', 97 | QT_IM_MODULE: 'ibus', 98 | QT_QPA_PLATFORMTHEME: 'appmenu-qt5', 99 | NVM_NODEJS_ORG_MIRROR: 'https://nodejs.org/dist', 100 | LC_IDENTIFICATION: 'es_ES.UTF-8', 101 | XDG_SESSION_TYPE: 'x11', 102 | PWD: '/home/desaroger/Runator/Projects/globalraces-api', 103 | JOB: 'unity-settings-daemon', 104 | XMODIFIERS: '@im=ibus', 105 | GNOME_KEYRING_PID: '', 106 | LANG: 'en_US.UTF-8', 107 | GDM_LANG: 'en_US', 108 | MANDATORY_PATH: '/usr/share/gconf/ubuntu.mandatory.path', 109 | LC_MEASUREMENT: 'es_ES.UTF-8', 110 | COMPIZ_CONFIG_PROFILE: 'ubuntu', 111 | IM_CONFIG_PHASE: '1', 112 | GDMSESSION: 'ubuntu', 113 | SESSIONTYPE: 'gnome-session', 114 | GTK2_MODULES: 'overlay-scrollbar', 115 | SHLVL: '1', 116 | HOME: '/home/desaroger', 117 | XDG_SEAT: 'seat0', 118 | LANGUAGE: 'en_US', 119 | GNOME_DESKTOP_SESSION_ID: 'this-is-deprecated', 120 | UPSTART_INSTANCE: '', 121 | UPSTART_EVENTS: 'xsession started', 122 | XDG_SESSION_DESKTOP: 'ubuntu', 123 | LOGNAME: 'desaroger', 124 | COMPIZ_BIN_PATH: '/usr/bin/', 125 | DBUS_SESSION_BUS_ADDRESS: 'unix:abstract=/tmp/dbus-6sZNHbtUXA', 126 | XDG_DATA_DIRS: '/usr/share/ubuntu:/usr/share/gnome:/usr/local/share/:/usr/share/:/var/lib/snapd/desktop', 127 | QT4_IM_MODULE: 'xim', 128 | NVM_BIN: '/home/desaroger/.nvm/versions/node/v7.2.1/bin', 129 | LESSOPEN: '| /usr/bin/lesspipe %s', 130 | NVM_IOJS_ORG_MIRROR: 'https://iojs.org/dist', 131 | INSTANCE: '', 132 | UPSTART_JOB: 'unity7', 133 | XDG_RUNTIME_DIR: '/run/user/1000', 134 | DISPLAY: ':0', 135 | XDG_CURRENT_DESKTOP: 'Unity', 136 | GTK_IM_MODULE: 'ibus', 137 | LESSCLOSE: '/usr/bin/lesspipe %s %s', 138 | LC_TIME: 'es_ES.UTF-8', 139 | LC_NAME: 'es_ES.UTF-8', 140 | XAUTHORITY: '/home/desaroger/.Xauthority', 141 | _: '/home/desaroger/.nvm/versions/node/v7.2.1/bin/pm2', 142 | PM2_USAGE: 'CLI', 143 | PM2_JSON_PROCESSING: 'true', 144 | status: 'online', 145 | pm_uptime: 1488110704847, 146 | axm_actions: [], 147 | axm_monitor: [Object], 148 | axm_options: [Object], 149 | axm_dynamic: {}, 150 | created_at: 1488110704847, 151 | pm_id: 1, 152 | restart_time: 0, 153 | unstable_restarts: 0, 154 | versioning: { 155 | type: 'git', 156 | url: 'git@bitbucket.org:runatorteam/globalraces-api.git', 157 | revision: 'b33c5b8213a3bdd1175179c81d2362a8a69cafa4', 158 | update_time: '2017-02-26T12:37:18.445Z', 159 | comment: 'Add tests to system.info', 160 | unstaged: true, 161 | branch: 'develop', 162 | remotes: [Object], 163 | remote: 'origin', 164 | branch_exists_on_remote: true, 165 | ahead: false, 166 | next_rev: null, 167 | prev_rev: 'aa08e6d0391a9b144150c11dd2e77b4ce15a9191', 168 | tags: [Object], 169 | repo_path: '/home/desaroger/Runator/Projects/globalraces-api' 170 | }, 171 | node_version: '7.2.1' }, 172 | pm_id: 1, 173 | monit: { memory: 180420608, cpu: 9 } 174 | } 175 | ]; 176 | -------------------------------------------------------------------------------- /test/mocks/bitbucket_push.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "X-Request-UUID": "32we352ewrt433e", 4 | "X-Event-Key": "repo:push", 5 | "User-Agent": "Bitbucket-Webhooks/2.0" 6 | }, 7 | "body": { 8 | "push": { 9 | "changes": [ 10 | { 11 | "old": { 12 | "type": "branch" 13 | }, 14 | "new": { 15 | "type": "branch", 16 | "name": "master" 17 | } 18 | } 19 | ] 20 | }, 21 | "actor": { 22 | "username": "desaroger", 23 | "display_name": "Roger Fos Soler", 24 | "type": "user", 25 | "uuid": "{02948f98-764-4566-7644-03937a689682}", 26 | "links": {} 27 | }, 28 | "repository": { 29 | "website": "", 30 | "scm": "git", 31 | "name": "pm2-hooks", 32 | "links": {}, 33 | "project": {}, 34 | "full_name": "desaroger/pm2-hooks", 35 | "owner": { 36 | "username": "desaroger", 37 | "display_name": "desaroger", 38 | "type": "user", 39 | "uuid": "{9efba52f-e18a-45df-b87f-6c663ac9d831}", 40 | "links": {} 41 | }, 42 | "type": "repository", 43 | "is_private": true, 44 | "uuid": "{711d3593-23d7-4e12-82e7-335577188776}" 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /test/mocks/github_push.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "x-github-event": "push", 4 | "x-hub-signature": "sha1=1f046d0d308f0faebfc9317a43ef0bcfb60018a7" 5 | }, 6 | "body": { 7 | "ref": "refs/heads/changes", 8 | "before": "9049f1265b7d61be4a8904a9a27120d2064dab3b", 9 | "after": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", 10 | "created": false, 11 | "deleted": false, 12 | "forced": false, 13 | "base_ref": null, 14 | "compare": "https://github.com/baxterthehacker/public-repo/compare/9049f1265b7d...0d1a26e67d8f", 15 | "commits": [ 16 | { 17 | "id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", 18 | "tree_id": "f9d2a07e9488b91af2641b26b9407fe22a451433", 19 | "distinct": true, 20 | "message": "Update README.md", 21 | "timestamp": "2015-05-05T19:40:15-04:00", 22 | "url": "https://github.com/baxterthehacker/public-repo/commit/0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", 23 | "author": { 24 | "name": "baxterthehacker", 25 | "email": "baxterthehacker@users.noreply.github.com", 26 | "username": "baxterthehacker" 27 | }, 28 | "committer": { 29 | "name": "baxterthehacker", 30 | "email": "baxterthehacker@users.noreply.github.com", 31 | "username": "baxterthehacker" 32 | }, 33 | "added": [], 34 | "removed": [], 35 | "modified": [ 36 | "README.md" 37 | ] 38 | } 39 | ], 40 | "head_commit": { 41 | "id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", 42 | "tree_id": "f9d2a07e9488b91af2641b26b9407fe22a451433", 43 | "distinct": true, 44 | "message": "Update README.md", 45 | "timestamp": "2015-05-05T19:40:15-04:00", 46 | "url": "https://github.com/baxterthehacker/public-repo/commit/0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", 47 | "author": { 48 | "name": "baxterthehacker", 49 | "email": "baxterthehacker@users.noreply.github.com", 50 | "username": "baxterthehacker" 51 | }, 52 | "committer": { 53 | "name": "baxterthehacker", 54 | "email": "baxterthehacker@users.noreply.github.com", 55 | "username": "baxterthehacker" 56 | }, 57 | "added": [], 58 | "removed": [], 59 | "modified": [ 60 | "README.md" 61 | ] 62 | }, 63 | "repository": { 64 | "id": 35129377, 65 | "name": "public-repo", 66 | "full_name": "baxterthehacker/public-repo", 67 | "owner": { 68 | "name": "baxterthehacker", 69 | "email": "baxterthehacker@users.noreply.github.com" 70 | }, 71 | "private": false, 72 | "html_url": "https://github.com/baxterthehacker/public-repo", 73 | "description": "", 74 | "fork": false, 75 | "url": "https://github.com/baxterthehacker/public-repo", 76 | "forks_url": "https://api.github.com/repos/baxterthehacker/public-repo/forks", 77 | "keys_url": "https://api.github.com/repos/baxterthehacker/public-repo/keys{/key_id}", 78 | "collaborators_url": "https://api.github.com/repos/baxterthehacker/public-repo/collaborators{/collaborator}", 79 | "teams_url": "https://api.github.com/repos/baxterthehacker/public-repo/teams", 80 | "hooks_url": "https://api.github.com/repos/baxterthehacker/public-repo/hooks", 81 | "issue_events_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/events{/number}", 82 | "events_url": "https://api.github.com/repos/baxterthehacker/public-repo/events", 83 | "assignees_url": "https://api.github.com/repos/baxterthehacker/public-repo/assignees{/user}", 84 | "branches_url": "https://api.github.com/repos/baxterthehacker/public-repo/branches{/branch}", 85 | "tags_url": "https://api.github.com/repos/baxterthehacker/public-repo/tags", 86 | "blobs_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/blobs{/sha}", 87 | "git_tags_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/tags{/sha}", 88 | "git_refs_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/refs{/sha}", 89 | "trees_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/trees{/sha}", 90 | "statuses_url": "https://api.github.com/repos/baxterthehacker/public-repo/statuses/{sha}", 91 | "languages_url": "https://api.github.com/repos/baxterthehacker/public-repo/languages", 92 | "stargazers_url": "https://api.github.com/repos/baxterthehacker/public-repo/stargazers", 93 | "contributors_url": "https://api.github.com/repos/baxterthehacker/public-repo/contributors", 94 | "subscribers_url": "https://api.github.com/repos/baxterthehacker/public-repo/subscribers", 95 | "subscription_url": "https://api.github.com/repos/baxterthehacker/public-repo/subscription", 96 | "commits_url": "https://api.github.com/repos/baxterthehacker/public-repo/commits{/sha}", 97 | "git_commits_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/commits{/sha}", 98 | "comments_url": "https://api.github.com/repos/baxterthehacker/public-repo/comments{/number}", 99 | "issue_comment_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/comments{/number}", 100 | "contents_url": "https://api.github.com/repos/baxterthehacker/public-repo/contents/{+path}", 101 | "compare_url": "https://api.github.com/repos/baxterthehacker/public-repo/compare/{base}...{head}", 102 | "merges_url": "https://api.github.com/repos/baxterthehacker/public-repo/merges", 103 | "archive_url": "https://api.github.com/repos/baxterthehacker/public-repo/{archive_format}{/ref}", 104 | "downloads_url": "https://api.github.com/repos/baxterthehacker/public-repo/downloads", 105 | "issues_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues{/number}", 106 | "pulls_url": "https://api.github.com/repos/baxterthehacker/public-repo/pulls{/number}", 107 | "milestones_url": "https://api.github.com/repos/baxterthehacker/public-repo/milestones{/number}", 108 | "notifications_url": "https://api.github.com/repos/baxterthehacker/public-repo/notifications", 109 | "+": "{?since,all,participating}", 110 | "labels_url": "https://api.github.com/repos/baxterthehacker/public-repo/labels{/name}", 111 | "releases_url": "https://api.github.com/repos/baxterthehacker/public-repo/releases{/id}", 112 | "created_at": 1430869212, 113 | "updated_at": "2015-05-05T23:40:12Z", 114 | "pushed_at": 1430869217, 115 | "git_url": "git://github.com/baxterthehacker/public-repo.git", 116 | "ssh_url": "git@github.com:baxterthehacker/public-repo.git", 117 | "clone_url": "https://github.com/baxterthehacker/public-repo.git", 118 | "svn_url": "https://github.com/baxterthehacker/public-repo", 119 | "homepage": null, 120 | "size": 0, 121 | "stargazers_logs": 0, 122 | "watchers_logs": 0, 123 | "language": null, 124 | "has_issues": true, 125 | "has_downloads": true, 126 | "has_wiki": true, 127 | "has_pages": true, 128 | "forks_logs": 0, 129 | "mirror_url": null, 130 | "open_issues_logs": 0, 131 | "forks": 0, 132 | "open_issues": 0, 133 | "watchers": 0, 134 | "default_branch": "master", 135 | "stargazers": 0, 136 | "master_branch": "master" 137 | }, 138 | "pusher": { 139 | "name": "baxterthehacker", 140 | "email": "baxterthehacker@users.noreply.github.com" 141 | }, 142 | "sender": { 143 | "login": "baxterthehacker", 144 | "id": 6752317, 145 | "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3", 146 | "gravatar_id": "", 147 | "url": "https://api.github.com/users/baxterthehacker", 148 | "html_url": "https://github.com/baxterthehacker", 149 | "followers_url": "https://api.github.com/users/baxterthehacker/followers", 150 | "following_url": "https://api.github.com/users/baxterthehacker/following{/other_user}", 151 | "gists_url": "https://api.github.com/users/baxterthehacker/gists{/gist_id}", 152 | "starred_url": "https://api.github.com/users/baxterthehacker/starred{/owner}{/repo}", 153 | "subscriptions_url": "https://api.github.com/users/baxterthehacker/subscriptions", 154 | "organizations_url": "https://api.github.com/users/baxterthehacker/orgs", 155 | "repos_url": "https://api.github.com/users/baxterthehacker/repos", 156 | "events_url": "https://api.github.com/users/baxterthehacker/events{/privacy}", 157 | "received_events_url": "https://api.github.com/users/baxterthehacker/received_events", 158 | "type": "User", 159 | "site_admin": false 160 | } 161 | } 162 | } -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by desaroger on 7/03/17. 3 | */ 4 | 5 | let { expect, isPromise } = require('./assets'); 6 | 7 | describe('utils', () => { 8 | describe('[isPromise]', () => { 9 | it('is a function', () => { 10 | expect(isPromise).to.be.a('function'); 11 | }); 12 | 13 | it('returns false if falsy', () => { 14 | expect(isPromise()).to.equal(false); 15 | expect(isPromise(false)).to.equal(false); 16 | expect(isPromise(null)).to.equal(false); 17 | }); 18 | 19 | it('returns false if non-object', () => { 20 | expect(isPromise('nope')).to.equal(false); 21 | }); 22 | 23 | it('returns false if .then is not a function', () => { 24 | expect(isPromise({})).to.equal(false); 25 | expect(isPromise({ then: 'hi' })).to.equal(false); 26 | }); 27 | 28 | it('returns false if .catch is not a function', () => { 29 | expect(isPromise({})).to.equal(false); 30 | expect(isPromise({ then: 'hi' })).to.equal(false); 31 | expect(isPromise({ then: 'hi', catch: 'hi' })).to.equal(false); 32 | }); 33 | 34 | it('returns true if another case', () => { 35 | let promise = { 36 | then() {}, 37 | catch() {} 38 | }; 39 | expect(isPromise(promise)).to.equal(true); 40 | }); 41 | }); 42 | }); 43 | --------------------------------------------------------------------------------