├── .gitignore ├── .npmignore ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── TODO.md ├── bin └── pod ├── help └── usage ├── hooks └── post-receive ├── lib ├── api.js ├── cli.js ├── conf.js ├── errors.js └── formatter.js ├── package.json ├── test ├── api.js ├── cli.sh └── fixtures │ ├── .podhook │ ├── .podrc │ ├── app.js │ └── cliapp.js └── web ├── app.js ├── static ├── robots.txt └── style.css ├── test.js └── views └── index.ejs /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | local 4 | temp -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | local 3 | TODO -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | branches: 3 | only: 4 | - master 5 | node_js: 6 | - "6.0" 7 | - "7.0" 8 | - "7.8" 9 | before_install: 10 | - git config --global user.email "yyx990803@gmail.com" 11 | - git config --global user.name "Evan You" 12 | before_script: 13 | - npm link -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Evan You 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 | # POD - git push deploy for Node.js 2 | 3 | ![screenshot](http://i.imgur.com/pda21KY.png) 4 | 5 | [![NPM version](https://badge.fury.io/js/pod.svg)](http://badge.fury.io/js/pod) [![Build Status](https://img.shields.io/travis/yyx990803/pod/master.svg)](https://travis-ci.org/yyx990803/pod) 6 | 7 | Core API JSCoverage: **95.52%** 8 | 9 | Pod simplifies the workflow of setting up, updating and managing multiple Node.js apps on a Linux server. Perfect for hosting personal Node stuff on a VPS. There are essentially two parts: 1. `git push` deploy (by using git hooks) and 2. process management (by using [pm2](https://github.com/Unitech/pm2)) 10 | 11 | It doesn't manage DNS routing for you (personally I'm doing that in Nginx) but you can use pod to run a [node-http-proxy](https://github.com/nodejitsu/node-http-proxy) server on port 80 that routes incoming requests to other apps. 12 | 13 | ## A Quick Taste 14 | 15 | **On the server:** 16 | 17 | ``` bash 18 | $ pod create myapp 19 | ``` 20 | 21 | **On your local machine:** 22 | 23 | ``` bash 24 | $ git clone ssh://your-server/pod_dir/repos/myapp.git 25 | # hack hack hack, commit 26 | # make sure your main file is app.js 27 | # or specify "main" in package.json 28 | $ git push 29 | ``` 30 | 31 | You can also just add it as a remote to an existing local repo: 32 | 33 | ``` bash 34 | $ git remote add deploy ssh://your-server/pod_dir/myapp.git 35 | $ git push deploy master 36 | ``` 37 | 38 | That's it! App should be automatically running after the push. For later pushes, app process will be restarted. There's more to it though, read on to find out more. 39 | 40 | - [First Time Walkthrough](https://github.com/yyx990803/pod/wiki/Setup-Walkthrough) 41 | - [Prerequisites](#prerequisites) 42 | - [Installation](#installation) 43 | - [CLI Usage](#cli-usage) 44 | - [Web Interface](#web-interface) 45 | - [Using a Remote GitHub Repo](#using-a-remote-github-repo) 46 | - [Configuration](#configuration) 47 | - [Using PM2 Directly](#using-pm2-directly) 48 | - [Custom Post-Receive Hook](#custom-post-receive-hook) 49 | - [Using the API](#using-the-api) 50 | - [Docker images](#Docker-images) 51 | - [Changelog](#changelog) 52 | 53 | ## Prerequisites 54 | 55 | - Node >= 0.10.x 56 | - git 57 | - properly set up ssh so you can push to a repo on the VPS via ssh 58 | 59 | ## Installation 60 | 61 | ``` bash 62 | $ [sudo] npm install -g pod 63 | ``` 64 | 65 | To make pod auto start all managed apps on system startup, you might also want to write a simple [upstart](http://upstart.ubuntu.com) script that contains something like this: 66 | 67 | ``` bash 68 | # /etc/init/pod.conf 69 | start on (local-filesystems and net-device-up IFACE!=lo) 70 | exec sudo -u /path/to/node /path/to/pod startall 71 | ``` 72 | 73 | The first time you run `pod` it will ask you where you want to put your stuff. The structure of the given directory will look like this: 74 | 75 | ``` bash 76 | . 77 | ├── repos # holds the bare .git repos 78 | │ └── example.git 79 | └── apps # holds the working copies 80 | └── example 81 | ├──app.js 82 | └──.podhook 83 | ``` 84 | 85 | ## CLI Usage 86 | 87 | ``` 88 | 89 | Usage: pod [command] 90 | 91 | Commands: 92 | 93 | create Create a new app 94 | remote Create a app from a remote GitHub repo 95 | rm Delete an app 96 | start Start an app monitored by pm2 97 | stop Stop an app 98 | restart Restart an app that's already running 99 | list List apps and status 100 | startall Start all apps not already running 101 | stopall Stop all apps 102 | restartall Restart all running apps 103 | prune Clean up dead files 104 | hooks Update hooks after a pod upgrade 105 | web [command] Start/stop/restart the web interface 106 | help You are reading it right now 107 | 108 | ``` 109 | 110 | ## Web Service 111 | 112 | ``` bash 113 | $ pod web [stop|restart|status] 114 | ``` 115 | 116 | This command will start the pod web service, by default at port 19999, which provides several functionalities: 117 | 118 | - `/` : a web interface that displays current apps status. 119 | - `/json` : returns app status data in json format. 120 | - `/jsonp` : accepts jsonp. This route must be enabled in config. 121 | - `/hooks/appname` : trigger fetch/restart for corresponding remote apps. 122 | 123 | Both `/` and `/json` require a basic http authentication. Make sure to set the username and password in the config file. 124 | 125 | ## Using a remote GitHub repo 126 | 127 | [Walkthrough](https://github.com/yyx990803/pod/wiki/Using-a-remote-repo) 128 | 129 | You can setup an app to track a remote GitHub repo by using the `pod remote` command: 130 | 131 | ``` bash 132 | $ pod remote my-remote-app username/repo 133 | ``` 134 | 135 | After this, add a webhook to your GitHub repo pointing at your web interface's `/hooks/my-remote-app`. The webhook will trigger a fetch and restart just like local apps. By default a remote app will be tracking the master branch only, if you want to track a different branch, you can change it in the config file. 136 | 137 | You can also set up a remote app to track an arbitrary git address. However in that case you need to manually make a POST request conforming to the [GitHub webhook payload](https://help.github.com/articles/post-receive-hooks). 138 | 139 | Starting in 0.8.2, GitLab webhooks are also supported. 140 | 141 | Starting in 0.8.6, Bitbucket webhooks are also supported. 142 | 143 | ## Configuration 144 | 145 | The config file lives at `~/.podrc`. Note since 0.7.0 all fields follow the underscore format so check your config file if things break after upgrading. 146 | 147 | Example Config: 148 | 149 | ``` js 150 | { 151 | // where pod puts all the stuff 152 | "root": "/srv", 153 | 154 | // default env 155 | "node_env": "development", 156 | 157 | // this can be overwritten in each app's package.json's "main" field 158 | // or in the app's configuration below using the "script" field 159 | "default_script": "app.js", 160 | 161 | // minimum uptime to be considered stable, 162 | // in milliseconds. If not set, all restarts 163 | // are considered unstable. 164 | "min_uptime": 3600000, 165 | 166 | // max times of unstable restarts allowed 167 | // before the app is auto stopped. 168 | "max_restarts": 10 169 | 170 | // config for the web interface 171 | "web": { 172 | // set these! default is admin/admin 173 | "username": "admin", 174 | "password": "admin", 175 | "port": 19999, 176 | // allow jsonp for web interface, defaults to false 177 | "jsonp": true 178 | }, 179 | 180 | "apps": { 181 | "example1": { 182 | 183 | // passed to the app as process.env.NODE_ENV 184 | // if not set, will inherit from global settings 185 | "node_env": "production", 186 | 187 | // passed to the app as process.env.PORT 188 | // if not set, pod will try to parse from app's 189 | // main file (for displaying only), but not 190 | // guarunteed to be correct. 191 | "port": 8080, 192 | 193 | // pod will look for this script before checking 194 | // in package.json of the app. 195 | "script": "dist/server.js", 196 | 197 | // *** any valid pm2 config here gets passed to pm2. *** 198 | 199 | // spin up 2 instances using cluster module 200 | "instances": 2, 201 | 202 | // pass in additional command line args to the app 203 | "args": "['--toto=heya coco', '-d', '1']", 204 | 205 | // file paths for stdout, stderr logs and pid. 206 | // will be in ~/.pm2/ if not specified 207 | "error_file": "/absolute/path/to/stderr.log", 208 | "out_file": "/absolute/path/to/stdout.log" 209 | }, 210 | "example2": { 211 | // you can override global settings 212 | "min_uptime": 1000, 213 | "max_restarts": 200 214 | }, 215 | "my-remote-app": { 216 | "remote": "yyx990803/my-remote-app", // github shorthand 217 | "branch": "test" // if not specified, defaults to master 218 | } 219 | }, 220 | 221 | // pass environment variables to all apps 222 | "env": { 223 | "SERVER_NAME": "Commodore", 224 | "CERT_DIR": "/path/to/certs" 225 | } 226 | } 227 | ``` 228 | 229 | ## Using PM2 Directly 230 | 231 | Pod relies on pm2 for process management under the hood. When installing pod, the `pm2` executable will also be linked globally. You can invoke `pm2` commands for more detailed process information. 232 | 233 | Logging is delegated to `pm2`. If you didn't set an app's `out_file` and `error_file` options, logs will default to be saved at `~/.pm2/logs`. 234 | 235 | If things go wrong and restarting is not fixing them, try `pm2 kill`. It terminates all pm2-managed processes and resets potential env variable problems. 236 | 237 | All pod commands only concerns apps present in pod's config file, so it's fine if you use pm2 separately to run additional processes. 238 | 239 | ## Custom Post-receive Hook 240 | 241 | By default pod will run `npm install` for you everytime you push to the repo. To override this behavior and run custom shell script before restarting the app, just include a `.podhook` file in your app. If `.podhook` exits with code other than 0, the app will not be restarted and will hard reset to the commit before the push. 242 | 243 | Example `.podhook`: 244 | 245 | ``` bash 246 | component install 247 | npm install 248 | grunt build 249 | grunt test 250 | passed=$? 251 | if [[ $passed != 0 ]]; then 252 | # test failed, exit. app's working tree on the server will be reset. 253 | exit $passed 254 | fi 255 | # restart is automatic so no need to include that here 256 | ``` 257 | 258 | You can also directly edit the post-receive script of an app found in `pod-root-dir/repos/my-app.git/hooks/post-receive` if you wish. 259 | 260 | ## Using the API 261 | 262 | NOTE: the API can only be used after you've initiated the config file via command line. 263 | 264 | `require('pod')` will return the API. You have to wait till it's ready to do anything with it: 265 | 266 | ``` js 267 | var pod = require('pod') 268 | pod.once('ready', function () { 269 | // ... do stuff 270 | }) 271 | ``` 272 | 273 | The API methods follow a conventional error-first callback style. Refer to the source for more details. 274 | 275 | ## Docker images 276 | 277 | Ready to go docker images: 278 | 279 | * [alpine linux](https://github.com/coderofsalvation/docker.alpine.nodejs.pod) 280 | * [ubuntu linux](https://github.com/raiscui/docker-pod) 281 | 282 | ## Changelog 283 | 284 | ### 0.9.1 285 | 286 | - Move to latest pm2 287 | - Added support for Node 7.x 288 | 289 | ### 0.9.0 290 | 291 | - Support `env` option in `.podrc` which passes environment variables to all apps managed by pod. 292 | - Fixed GitHub ping event error when using remote GitHub repo. 293 | 294 | ### 0.8.6 295 | 296 | - Added support for Bitbucket webhooks. 297 | - Added ability to specify entry point in app's config in `.podrc` by using the `script` field. 298 | - Fixed issue with the `readline` module blocking stdin. This caused issues when attempting to clone a repository that required a username/password. 299 | 300 | ### 0.8.0 301 | 302 | - Upgrade pm2 to 0.12.9, which should make pod now work properly with Node 0.11/0.12 and latest stable iojs. 303 | - Fix web services to accommodate github webhook format change (#29) 304 | - Now links the pm2 executable automatically when installed 305 | 306 | ### 0.7.4 307 | 308 | - Fix web service `strip()` function so it processes github ssh urls correctly. (Thanks to @mathisonian) 309 | - Behavior regarding `main` field in `package.json` is now more npm compliant. (e.g. it now allows omitting file extensions) 310 | 311 | ### 0.7.3 312 | 313 | - Add a bit more information for first time use 314 | - Renamed the web service process name to `pod-web-service` from `pod-web-interface`. 315 | - Fixed web service not refreshing config on each request 316 | 317 | ### 0.7.2 318 | 319 | - Add styling for the web interface. 320 | 321 | ### 0.7.1 322 | 323 | - Now pod automatically converts outdated config files to 0.7.0 compatible format. 324 | 325 | ### 0.7.0 326 | 327 | - Config file now conforms to underscore-style naming: `nodeEnv` is now `node_env`, and `defaultScript` is now `default_script`. Consult the [configuration](#configuration) section for more details. 328 | - Added `pod web` and `pod remote` commands. See [web interface](#web-interface) and [using a remote github repo](#using-a-remote-github-repo) for more details. 329 | - Removed `pod config` and `pod edit`. 330 | - Drop support for Node v0.8. 331 | 332 | ### 0.6.0 333 | 334 | - The post receive hook now uses `git fetch --all` + `git reset --hard origin/master` instead of a pull. This allows users to do forced pushes that isn't necesarrily ahead of the working copy. 335 | - Added `pod prune` and `pod hooks` commands. Make sure to run `pod hooks` after upgrading pod, as you will want to update the hooks that are already created in your existing apps. 336 | - Upgraded to pm2 0.6.7 337 | 338 | ## License 339 | 340 | [MIT](http://opensource.org/licenses/MIT) 341 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yyx990803/pod/97a546c2c632466898947a6ad3480ce6e7e4f99e/TODO.md -------------------------------------------------------------------------------- /bin/pod: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../lib/cli') -------------------------------------------------------------------------------- /help/usage: -------------------------------------------------------------------------------- 1 | 2 | Usage: pod [command] 3 | 4 | Commands: 5 | 6 | create Create a new app 7 | remote Create a app from a remote GitHub repo 8 | rm Delete an app 9 | start Start an app monitored by pm2 10 | stop Stop an app 11 | restart Restart an app that's already running 12 | list List apps and status 13 | startall Start all apps not already running 14 | stopall Stop all apps 15 | restartall Restart all running apps 16 | prune Clean up dead files 17 | hooks Update hooks after a pod upgrade 18 | web [command] Start/stop/restart the web interface 19 | help You are reading it right now 20 | -------------------------------------------------------------------------------- /hooks/post-receive: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # update working tree 4 | cd {{pod_dir}}/apps/{{app}} 5 | unset GIT_DIR 6 | # save last commit 7 | LAST_COMMIT=`git log -1 | awk 'NR==1 {print $2}'` 8 | # this is basiclly a force pull 9 | # so even if you force pushed this can still work 10 | git fetch --all 11 | git reset --hard origin/master 12 | 13 | # if has .podhook, execute that; otherwise default 14 | if [ -f .podhook ]; then 15 | bash .podhook 16 | rc=$? 17 | if [[ $rc != 0 ]]; then 18 | echo "`tput setaf 1`ERROR: .podhook exited with code $rc, working tree is reverted.`tput sgr0`" 19 | git reset $LAST_COMMIT --hard 20 | exit $rc 21 | fi 22 | elif [ -f package.json ]; then 23 | npm install 24 | fi 25 | 26 | pod stop {{app}} 27 | pod start {{app}} -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | let fs = require('fs'), 2 | path = require('path'), 3 | async = require('async'), 4 | mkdirp = require('mkdirp'), 5 | colors = require('colors'), 6 | pm2 = require('pm2'), 7 | pm2cst = require('pm2/constants.js'), 8 | pm2prepare = require('pm2/lib/Common.js').prepareAppConf, 9 | // Client is pm2's RPC daemon, we have to use it to get 10 | // some custom behavior that is not exposed by pm2's CLI. 11 | Client = pm2.Client, 12 | exec = require('child_process').exec, 13 | Emitter = require('events').EventEmitter, 14 | debug = require('debug')('api') 15 | 16 | var conf = require('./conf'), 17 | ERRORS = require('./errors'), 18 | formatter = require('./formatter'), 19 | hookTemplate = fs.readFileSync(__dirname + '/../hooks/post-receive', 'utf-8') 20 | 21 | // Load config data 22 | var globalConfigPath = conf.path, 23 | webInterfaceId = conf.webId, 24 | globalConfig = readJSON(globalConfigPath) 25 | 26 | upgradeConf() 27 | 28 | // If env var is present, overwrite root dir 29 | // mostly for testing. 30 | if (process.env.POD_ROOT_DIR) globalConfig.root = process.env.POD_ROOT_DIR 31 | 32 | // create default folders 33 | if (!fs.existsSync(globalConfig.root)) mkdirp.sync(globalConfig.root) 34 | if (!fs.existsSync(globalConfig.root + '/apps')) fs.mkdirSync(globalConfig.root + '/apps') 35 | if (!fs.existsSync(globalConfig.root + '/repos')) fs.mkdirSync(globalConfig.root + '/repos') 36 | 37 | // The api is an emitter 38 | var api = new Emitter() 39 | 40 | // init and connect to pm2 41 | //pm2.pm2Init() 42 | pm2.connect(function () { 43 | api.emit('ready'); 44 | Client = pm2.Client; 45 | }) 46 | 47 | api.version = require('../package.json').version 48 | 49 | api.createApp = function (appname, options, callback) { 50 | 51 | if (typeof options === 'function') { 52 | callback = options 53 | options = null 54 | } 55 | 56 | if (globalConfig.apps[appname] || appname === webInterfaceId) { 57 | return abort(ERRORS.EXISTS, callback, { appname: appname }) 58 | } 59 | 60 | var paths = getAppPaths(appname) 61 | async.parallel([ 62 | function (done) { 63 | // write config file 64 | var opts = {} 65 | // merge options 66 | if (options) { 67 | for (var o in options) { 68 | opts[o] = options[o] 69 | } 70 | } 71 | globalConfig.apps[appname] = opts 72 | var data = JSON.stringify(globalConfig, null, 4) 73 | fs.writeFile(globalConfigPath, data, function (err) { 74 | done(err, 'updated config.') 75 | }) 76 | }, 77 | function (done) { 78 | // create repo 79 | if (options && options.remote) { 80 | createRemoteApp(paths, options.remote, done) 81 | } else { 82 | createAppRepo(paths, done) 83 | } 84 | } 85 | ], 86 | function (err, msgs) { 87 | var repoMsgs = msgs.pop() 88 | msgs = msgs.concat(repoMsgs) 89 | return callback(err, msgs, api.getAppInfo(appname)) 90 | }) 91 | } 92 | 93 | api.removeApp = function (appname, callback) { 94 | 95 | if (appname === webInterfaceId) { 96 | return abort(ERRORS.WEB, callback) 97 | } 98 | 99 | var app = api.getAppInfo(appname) 100 | if (!app) { 101 | return abort(ERRORS.NOT_FOUND, callback, { appname: appname }) 102 | } 103 | 104 | api.stopApp(appname, function (err) { 105 | if (!err || /is not running/.test(err.toString())) { 106 | async.parallel([ 107 | function (done) { 108 | // remove files 109 | exec('rm -rf ' + 110 | app.repoPath + ' ' + 111 | app.workPath, 112 | done 113 | ) 114 | }, 115 | function (done) { 116 | // rewrite config 117 | delete globalConfig.apps[appname] 118 | var data = JSON.stringify(globalConfig, null, 4) 119 | fs.writeFile(globalConfigPath, data, done) 120 | } 121 | ], 122 | function (err) { 123 | if (err) return callback(err) 124 | return callback(null, 'deleted app: ' + appname.yellow) 125 | }) 126 | } else { 127 | return callback(err) 128 | } 129 | }) 130 | 131 | } 132 | 133 | api.startApp = function (appname, callback) { 134 | 135 | var app = api.getAppInfo(appname) 136 | if (!app) { 137 | return abort(ERRORS.NOT_FOUND, callback, { appname: appname }) 138 | } 139 | 140 | debug('checking if app main script exists...') 141 | fs.exists(app.script, function (exists) { 142 | if (!exists) { 143 | return abort(ERRORS.NO_SCRIPT, callback, { appname: appname, script: app.script }) 144 | } 145 | debug('checking if app is already running...') 146 | Client.executeRemote('getMonitorData', {}, function (err, list) { 147 | if (err) return callback(err) 148 | var runningProcs = findInList(appname, list) 149 | if (!runningProcs) { 150 | debug('attempting to start app...') 151 | pm2.start(prepareConfig(app), function (err) { 152 | if (err) return callback(err) 153 | return callback(null, appname.yellow + ' running on ' + (app.port || 'unknown port')) 154 | }) 155 | } else { 156 | return abort(ERRORS.RUNNING, callback, { appname: appname }); 157 | } 158 | }); 159 | }) 160 | } 161 | 162 | api.startAllApps = function (callback) { 163 | async.map( 164 | Object.keys(globalConfig.apps), 165 | api.startApp, 166 | callback 167 | ) 168 | } 169 | 170 | api.stopApp = function (appname, callback) { 171 | 172 | var app = api.getAppInfo(appname) 173 | if (!app) { 174 | return abort(ERRORS.NOT_FOUND, callback, { appname: appname }) 175 | } 176 | 177 | Client.executeRemote('getMonitorData', {}, function (err, list) { 178 | if (err) return callback(err) 179 | var runningProcs = findInList(appname, list) 180 | if (!runningProcs) { 181 | return callback(null, appname.yellow + ' is not running.') 182 | } else { 183 | async.map(runningProcs, function (proc, done) { 184 | Client.executeRemote('stopProcessId', proc.pm_id, function (err) { 185 | if (err) return done(err) 186 | Client.executeRemote('deleteProcessId', proc.pm_id, done) 187 | }) 188 | }, function (err) { 189 | if (err) return callback(err) 190 | var l = runningProcs.length 191 | return callback( 192 | null, 193 | appname.yellow + ' stopped.' + 194 | (l > 1 ? (' (' + l + ' instances)').grey : '') 195 | ) 196 | }) 197 | } 198 | }) 199 | } 200 | 201 | api.stopAllApps = function (callback) { 202 | // only stop ones in the config 203 | async.map( 204 | Object.keys(globalConfig.apps), 205 | api.stopApp, 206 | callback 207 | ) 208 | } 209 | 210 | api.restartApp = function (appname, callback) { 211 | 212 | var app = api.getAppInfo(appname) 213 | if (!app) { 214 | return abort(ERRORS.NOT_FOUND, callback, { appname: appname }) 215 | } 216 | 217 | Client.executeRemote('getMonitorData', {}, function (err, list) { 218 | if (err) return callback(err) 219 | var runningProcs = findInList(appname, list) 220 | if (!runningProcs) { 221 | return abort(ERRORS.NOT_RUNNING, callback, { appname: appname }) 222 | } else { 223 | async.map(runningProcs, restart, function (err) { 224 | if (err) return callback(err) 225 | var l = runningProcs.length 226 | return callback( 227 | null, 228 | appname.yellow + ' restarted.' + 229 | (l > 1 ? (' (' + l + ' instances)').grey : '') 230 | ) 231 | }) 232 | } 233 | }) 234 | } 235 | 236 | api.restartAllApps = function (callback) { 237 | Client.executeRemote('getMonitorData', {}, function (err, list) { 238 | if (err) return callback(err) 239 | var runningProcs = [] 240 | list.forEach(function (proc) { 241 | if (proc.pm2_env.name in globalConfig.apps) { 242 | runningProcs.push(proc) 243 | } 244 | }) 245 | async.map(runningProcs, restart, function (err, msgs) { 246 | callback(err, msgs.map(function (msg) { 247 | return 'instance of ' + msg 248 | })) 249 | }) 250 | }) 251 | } 252 | 253 | api.listApps = function (callback) { 254 | var appList = Object.keys(globalConfig.apps) 255 | if (!appList.length) { 256 | return process.nextTick(function () { 257 | return callback(null, []) 258 | }) 259 | } 260 | Client.executeRemote('getMonitorData', {}, function (err, list) { 261 | if (err) return callback(err) 262 | return callback(null, appList.map(function (appname) { 263 | var app = api.getAppInfo(appname) 264 | app.instances = findInList(appname, list) 265 | app.broken = isBroken(app) 266 | return formatter.format(app) 267 | })) 268 | }) 269 | } 270 | 271 | api.prune = function (callback) { 272 | var appList = Object.keys(globalConfig.apps), 273 | pruned = [] 274 | async.parallel([ 275 | // clean root dir 276 | function (done) { 277 | fs.readdir(globalConfig.root, function (err, files) { 278 | if (err) return callback(err) 279 | async.map(files, function (f, next) { 280 | if (f !== 'apps' && f !== 'repos') { 281 | f = globalConfig.root + '/' + f 282 | pruned.push(f) 283 | removeFile(f, next) 284 | } else { 285 | next() 286 | } 287 | }, done) 288 | }) 289 | }, 290 | // clean apps dir 291 | function (done) { 292 | fs.readdir(globalConfig.root + '/apps', function (err, files) { 293 | if (err) return callback(err) 294 | async.map(files, function (f, next) { 295 | if (appList.indexOf(f) < 0) { 296 | f = globalConfig.root + '/apps/' + f 297 | pruned.push(f) 298 | removeFile(f, next) 299 | } else { 300 | next() 301 | } 302 | }, done) 303 | }) 304 | }, 305 | // clean repos dir 306 | function (done) { 307 | fs.readdir(globalConfig.root + '/repos', function (err, files) { 308 | if (err) return callback(err) 309 | async.map(files, function (f, next) { 310 | var base = f.replace('.git', '') 311 | if (appList.indexOf(base) < 0 || f.indexOf('.git') === -1) { 312 | f = globalConfig.root + '/repos/' + f 313 | pruned.push(f) 314 | removeFile(f, next) 315 | } else { 316 | next() 317 | } 318 | }, done) 319 | }) 320 | } 321 | ], function (err) { 322 | var msg = pruned.length 323 | ? 'pruned:\n' + pruned.join('\n').grey 324 | : 'root directory is clean.' 325 | return callback(err, msg) 326 | }) 327 | } 328 | 329 | api.updateHooks = function (callback) { 330 | var appList = Object.keys(globalConfig.apps), 331 | updated = [] 332 | async.map(appList, function (app, next) { 333 | var info = getAppPaths(app) 334 | createHook(info, function (err) { 335 | if (!err) updated.push(info.name) 336 | next(err) 337 | }) 338 | }, function (err) { 339 | return callback(err, 'updated hooks for:\n' + updated.join('\n').yellow) 340 | }) 341 | } 342 | 343 | api.getAppInfo = function (appname) { 344 | if (appname === webInterfaceId) { 345 | return webConfig() 346 | } 347 | var info = getAppPaths(appname) 348 | info.config = globalConfig.apps[appname] 349 | if (!info.config) return 350 | info.script = path.resolve(info.workPath, getAppMainScript(info.workPath, appname) || globalConfig.default_script) 351 | info.port = info.config.port || sniffPort(info.script) || null 352 | return info 353 | } 354 | 355 | api.getConfig = function () { 356 | return globalConfig 357 | } 358 | 359 | api.reloadConfig = function () { 360 | globalConfig = readJSON(globalConfigPath) 361 | return globalConfig 362 | } 363 | 364 | api.proxy = function () { 365 | return Client.executeRemote.apply(Client, arguments) 366 | } 367 | 368 | // helpers 369 | 370 | function restart(app, callback) { 371 | Client.executeRemote('restartProcessId', { id: app.pm_id }, function (err) { 372 | if (err) return callback(err) 373 | return callback(null, app.pm2_env.name.yellow + ' restarted') 374 | }) 375 | } 376 | 377 | function getAppPaths(app) { 378 | return { 379 | name: app, 380 | repoPath: globalConfig.root + '/repos/' + app + '.git', 381 | workPath: globalConfig.root + '/apps/' + app 382 | } 383 | } 384 | 385 | function createAppRepo(info, done) { 386 | async.series([ 387 | function (next) { 388 | // create repo directory 389 | fs.mkdir(info.repoPath, next) 390 | }, 391 | function (next) { 392 | // init bare repo 393 | exec('git --git-dir ' + info.repoPath + ' --bare init', function (err) { 394 | next(err, 'created bare repo at ' + info.repoPath.yellow) 395 | }) 396 | }, 397 | function (next) { 398 | // create post-receive hook 399 | createHook(info, function (err) { 400 | next(err, 'created post-receive hook.') 401 | }) 402 | }, 403 | function (next) { 404 | // clone an empty working copy 405 | exec('git clone ' + info.repoPath + ' \"' + info.workPath+'\"', function (err) { 406 | next(err, 'created empty working copy at ' + info.workPath.yellow) 407 | }) 408 | } 409 | ], function (err, msgs) { 410 | msgs.shift() 411 | done(err, msgs) 412 | }) 413 | } 414 | 415 | function createRemoteApp(info, remote, done) { 416 | remote = expandRemote(remote) 417 | exec('git clone ' + remote + ' \"' + info.workPath+'\"', function (err) { 418 | done(err, [ 419 | 'created remote app at ' + info.workPath.yellow, 420 | 'tracking remote: ' + remote.cyan 421 | ]) 422 | }) 423 | } 424 | 425 | function expandRemote(remote) { 426 | var m = remote.match(/^([\w-_]+)\/([\w-_]+)$/) 427 | return m 428 | ? 'https://github.com/' + m[1] + '/' + m[2] + '.git' 429 | : remote 430 | } 431 | 432 | function createHook(info, done) { 433 | var hookPath = info.repoPath + '/hooks/post-receive' 434 | async.waterfall([ 435 | function (next) { 436 | var data = hookTemplate 437 | .replace(/\{\{pod_dir\}\}/g, globalConfig.root) 438 | .replace(/\{\{app\}\}/g, info.name) 439 | fs.writeFile(hookPath, data, next) 440 | }, 441 | function (next) { 442 | fs.chmod(hookPath, '0777', next) 443 | } 444 | ], done) 445 | } 446 | 447 | function findInList(appname, list) { 448 | if (!list || !list.length) return false 449 | var ret = [], proc 450 | for (var i = 0, j = list.length; i < j; i++) { 451 | proc = list[i] 452 | if ( 453 | proc.pm2_env.status !== 'stopped' && 454 | proc.pm2_env.name === appname 455 | ) { 456 | ret.push(list[i]) 457 | } 458 | } 459 | return ret.length > 0 ? ret : null 460 | } 461 | 462 | function getAppMainScript(workPath, appname) { 463 | var pkg = readJSON(workPath + '/package.json') 464 | var main 465 | 466 | if (globalConfig.apps[appname].script) { 467 | main = globalConfig.apps[appname].script 468 | } else if (pkg && pkg.main) { 469 | main = pkg.main 470 | } 471 | 472 | if (main) { 473 | if (/\.js$/.test(main)) { 474 | return main 475 | } else { 476 | var mainPath = path.resolve(workPath, main) 477 | if (fs.existsSync(mainPath)) { 478 | return fs.statSync(mainPath).isDirectory() 479 | ? main + '/index.js' 480 | : main 481 | } else { 482 | return main + '.js' 483 | } 484 | } 485 | } 486 | } 487 | 488 | function readJSON(file) { 489 | if (!fs.existsSync(file)) { 490 | return null 491 | } else { 492 | return JSON.parse(fs.readFileSync(file, 'utf-8')) 493 | } 494 | } 495 | 496 | function sniffPort(script) { 497 | if (fs.existsSync(script)) { 498 | // sniff port 499 | var content = fs.readFileSync(script, 'utf-8'), 500 | portMatch = content.match(/\.listen\(\D*(\d\d\d\d\d?)\D*\)/) 501 | 502 | if (!portMatch) { 503 | var portVariableMatch = content.match(/\.listen\(\s*([a-zA-Z_$]+)\s*/) 504 | 505 | if (portVariableMatch) { 506 | portMatch = content.match(new RegExp(portVariableMatch[1] + '\\s*=\\D*(\\d\\d\\d\\d\\d?)\\D')) 507 | } 508 | } 509 | 510 | return portMatch ? portMatch[1] : null 511 | } 512 | } 513 | 514 | function isBroken(app) { 515 | return app.name !== webInterfaceId && 516 | ( 517 | (!app.config.remote && !fs.existsSync(app.repoPath)) || 518 | !fs.existsSync(app.workPath) 519 | ) 520 | } 521 | 522 | function removeFile(f, cb) { 523 | var isDir = fs.statSync(f).isDirectory() 524 | fs[isDir ? 'rmdir' : 'unlink'](f, cb) 525 | } 526 | 527 | function webConfig() { 528 | var p = path.resolve(__dirname, '../web') 529 | return { 530 | name: webInterfaceId, 531 | workPath: p, 532 | config: globalConfig.web, 533 | script: p + '/app.js', 534 | port: globalConfig.web.port || 19999 535 | } 536 | } 537 | 538 | function prepareConfig(appInfo) { 539 | 540 | var conf = { 541 | name: appInfo.name, 542 | script: appInfo.script, 543 | env: { 544 | NODE_ENV: appInfo.config.node_env || globalConfig.node_env || 'development', 545 | PORT: appInfo.config.port 546 | } 547 | } 548 | 549 | // copy other options and pass it to pm2 550 | for (var o in appInfo.config) { 551 | if ( 552 | o !== 'port' && 553 | o !== 'node_env' && 554 | o !== 'remote' && 555 | o !== 'username' && 556 | o !== 'password' && 557 | o !== 'jsonp' 558 | ) { 559 | conf[o] = appInfo.config[o] 560 | } 561 | } 562 | 563 | for (o in globalConfig.env) { 564 | conf.env[o] = globalConfig.env[o] 565 | } 566 | 567 | // constraints, fallback to global config 568 | conf.min_uptime = conf.min_uptime || globalConfig.min_uptime || undefined 569 | conf.max_restarts = conf.max_restarts || globalConfig.max_restarts || undefined 570 | 571 | return conf; 572 | } 573 | 574 | function abort(e, callback, data) { 575 | var msg = e.msg 576 | if (data) { 577 | msg = msg.replace(/{{(.+?)}}/g, function (m, p1) { 578 | return data[p1] || '' 579 | }) 580 | } 581 | var err = new Error(msg) 582 | err.code = e.code 583 | return process.nextTick(function () { 584 | return callback(err) 585 | }) 586 | } 587 | 588 | function upgradeConf() { 589 | if ( 590 | globalConfig.web && 591 | globalConfig.node_env && 592 | globalConfig.default_script 593 | ) return 594 | 595 | if (!globalConfig.web) globalConfig.web = {} 596 | var fieldsToConvert = { 597 | 'nodeEnv': 'node_env', 598 | 'defaultScript': 'default_script', 599 | 'fileOutput': 'out_file', 600 | 'fileError': 'error_file', 601 | 'pidFile': 'pid_file', 602 | 'minUptime': 'min_uptime', 603 | 'maxRestarts': 'max_restarts' 604 | } 605 | convert(globalConfig) 606 | fs.writeFile(globalConfigPath, JSON.stringify(globalConfig, null, 4)) 607 | 608 | function convert(conf) { 609 | for (var key in conf) { 610 | var converted = fieldsToConvert[key] 611 | if (converted) { 612 | conf[converted] = conf[key] 613 | delete conf[key] 614 | } else if (Object.prototype.toString.call(conf[key]) === '[object Object]') { 615 | convert(conf[key]) 616 | } 617 | } 618 | } 619 | } 620 | 621 | module.exports = api 622 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | path = require('path'), 3 | spawn = require('child_process').spawn, 4 | colors = require('colors'), 5 | mkdirp = require('mkdirp'), 6 | Table = require('cli-table'), 7 | format = require('./formatter').format, 8 | api 9 | 10 | var rl = require('readline').createInterface({ 11 | input: process.stdin, 12 | output: process.stdout 13 | }) 14 | 15 | var conf = require('./conf'), 16 | globalConfigPath = conf.path, 17 | webInterfaceId = conf.webId 18 | 19 | var tableOptions = { 20 | style: { compact: true, 'padding-left': 4 }, 21 | chars: { 'top': '' , 'top-mid': '' , 'top-left': '' , 'top-right': '' 22 | , 'bottom': '' , 'bottom-mid': '' , 'bottom-left': '' , 'bottom-right': '' 23 | , 'left': '' , 'left-mid': '' , 'mid': '' , 'mid-mid': '' 24 | , 'right': '' , 'right-mid': '' , 'middle': ' ' }, 25 | head: ['name', 'status', 'port', 'restarts', 'uptime', 'memory', 'CPU'].map(function (field) { 26 | return field.grey 27 | }) 28 | } 29 | 30 | var cli = { 31 | 32 | help: function () { 33 | console.log('\n POD '.green + 'v' + api.version) 34 | console.log(fs.readFileSync(__dirname + '/../help/usage', 'utf-8')) 35 | process.exit(0) 36 | }, 37 | 38 | create: function (appname) { 39 | if (!appname) exit() 40 | 41 | rl.close() 42 | api.createApp(appname, output) 43 | }, 44 | 45 | remote: function (appname, repo, branch) { 46 | if (!appname || !repo) exit() 47 | 48 | rl.close() 49 | api.createApp(appname, { 50 | remote: repo, 51 | branch: branch 52 | }, output) 53 | }, 54 | 55 | rm: function (arg1, arg2) { 56 | var force = false, 57 | appname = arg1 58 | if (arg1 === '-f') { 59 | force = true 60 | appname = arg2 61 | } else if (arg2 === '-f') { 62 | force = true 63 | } 64 | if (!appname) exit() 65 | if (force) { 66 | rl.close() 67 | return api.removeApp(appname, output) 68 | } 69 | rl.question('really delete ' + appname.yellow + '? (y/N)', function (reply) { 70 | if (reply.toLowerCase() === 'y') { 71 | rl.close() 72 | api.removeApp(appname, output) 73 | } else { 74 | log('aborted.') 75 | process.exit(0) 76 | } 77 | }) 78 | }, 79 | 80 | start: function (appname) { 81 | if (!appname) exit() 82 | api.startApp(appname, output) 83 | }, 84 | 85 | stop: function (appname) { 86 | if (!appname) exit() 87 | api.stopApp(appname, output) 88 | }, 89 | 90 | restart: function (appname) { 91 | if (!appname) exit() 92 | api.restartApp(appname, output) 93 | }, 94 | 95 | startall: function () { 96 | api.startAllApps(output) 97 | }, 98 | 99 | stopall: function () { 100 | api.stopAllApps(output) 101 | }, 102 | 103 | restartall: function () { 104 | api.restartAllApps(output) 105 | }, 106 | 107 | list: function () { 108 | api.listApps(function (err, apps) { 109 | if (err) { 110 | warn(err) 111 | process.exit(1) 112 | } 113 | if (!apps || !apps.length) { 114 | log('no apps found.') 115 | } else { 116 | var table = new Table(tableOptions) 117 | table.push(new Array(7)) 118 | apps.forEach(function (app) { 119 | table.push(toArray(app)) 120 | }) 121 | console.log() 122 | console.log(table.toString()) 123 | console.log() 124 | } 125 | process.exit(0) 126 | }) 127 | }, 128 | 129 | prune: function () { 130 | api.prune(output) 131 | }, 132 | 133 | hooks: function () { 134 | api.updateHooks(output) 135 | }, 136 | 137 | web: function (action) { 138 | if (action === 'stop') { 139 | api.stopApp(webInterfaceId, output) 140 | } else if (action === 'restart') { 141 | api.restartApp(webInterfaceId, output) 142 | } else if (action === 'status') { 143 | logStatus() 144 | } else { 145 | api.startApp(webInterfaceId, function (err, msg) { 146 | if (msg && msg.indexOf('already') > 0) { // web interface already on 147 | logStatus() 148 | } else { 149 | output(err, msg) 150 | } 151 | }) 152 | } 153 | 154 | function logStatus () { 155 | api.proxy('getMonitorData', {}, function (err, list) { 156 | if (err) return output(err) 157 | list.forEach(function (proc) { 158 | if (proc.pm2_env.name === webInterfaceId) { 159 | var app = api.getAppInfo(webInterfaceId) 160 | app.instances = [proc] 161 | var table = new Table(tableOptions) 162 | table.push(toArray(format(app))) 163 | console.log(table.toString()) 164 | process.exit(0) 165 | } 166 | }) 167 | output(null, 'web interface is not running.') 168 | }) 169 | } 170 | } 171 | } 172 | 173 | // Init 174 | if (!fs.existsSync(globalConfigPath)) { 175 | if (process.env.POD_ROOT_DIR) return createRootDir(process.env.POD_ROOT_DIR) 176 | console.log( 177 | 'Hello! It seems it\'s your first time running pod on this machine.\n' + 178 | 'Please specify a directory for pod to put stuff in.\n' + 179 | '- Make sure your account has full access to that directory.\n' + 180 | '- You can use relative paths (resolved against your cwd).' 181 | ) 182 | rl.question('path: ', createRootDir) 183 | } else { 184 | loadAPI() 185 | } 186 | 187 | function createRootDir (dir) { 188 | if (dir.charAt(0) === '~') { // home path 189 | dir = process.env.HOME + dir.slice(1) 190 | } else { 191 | dir = path.resolve(process.cwd(), dir) 192 | } 193 | if (fs.existsSync(dir)) { 194 | if (!fs.statSync(dir).isDirectory()) { 195 | warn('target path ' + dir.grey + ' is not a directory.') 196 | process.exit(1) 197 | } else { 198 | initConfig(dir) 199 | loadAPI() 200 | } 201 | } else { 202 | if (process.env.TEST) return make() 203 | rl.question('target path ' + dir.grey + ' doesn\'t exist. create it? (y/N)', function (reply) { 204 | if (reply.toLowerCase() === 'y') { 205 | make() 206 | } else { 207 | process.exit(0) 208 | } 209 | }) 210 | } 211 | 212 | function make () { 213 | console.log() 214 | mkdirp.sync(dir) 215 | log('created root directory: ' + dir.grey) 216 | fs.mkdirSync(dir + '/repos') 217 | log('created repos directory: ' + (dir + '/repos').grey) 218 | fs.mkdirSync(dir + '/apps') 219 | log('created apps directory: ' + (dir + '/apps').grey) 220 | initConfig(dir) 221 | log('created config file at: ' + globalConfigPath.grey) 222 | loadAPI() 223 | } 224 | } 225 | 226 | function initConfig (root) { 227 | var globalConfig = { 228 | root: root, 229 | node_env: 'development', 230 | default_script: 'app.js', 231 | apps: {}, 232 | web: {} 233 | } 234 | mkdirp.sync(globalConfigPath.slice(0, globalConfigPath.lastIndexOf('/'))) 235 | fs.writeFileSync(globalConfigPath, JSON.stringify(globalConfig, null, 4)) 236 | } 237 | 238 | // env editor vars might contain args, which breaks edit() 239 | function stripArgs (cmd) { 240 | if (cmd) return cmd.split(' ')[0] 241 | } 242 | 243 | function loadAPI () { 244 | api = require('./api') 245 | api.once('ready', parseCommand) 246 | } 247 | 248 | function parseCommand () { 249 | var args = process.argv.slice(2), 250 | command = args[0] || 'help' 251 | if (cli[command]) { 252 | cli[command].apply(null, args.slice(1)) 253 | } else { 254 | if (command) { 255 | warn('unknown command ' + command.red) 256 | process.exit(1) 257 | } 258 | } 259 | } 260 | 261 | function output (err, msg) { 262 | if (err) { 263 | warn(err) 264 | process.exit(1) 265 | } else { 266 | log(msg) 267 | process.exit(0) 268 | } 269 | } 270 | 271 | function log (msg) { 272 | if (!Array.isArray(msg)) { 273 | console.log('POD '.green + msg) 274 | } else { 275 | msg.forEach(function (m) { 276 | console.log('POD '.green + m) 277 | }) 278 | } 279 | } 280 | 281 | function warn (err) { 282 | err = err.toString().replace('Error: ', '') 283 | console.warn('POD '.green + 'ERR '.red + err) 284 | } 285 | 286 | function toArray (app) { 287 | 288 | var restarts = app.restarts 289 | if (restarts === 0) restarts = restarts.toString().green 290 | if (restarts > 0 && restarts < 10) restarts = restarts.toString().yellow 291 | if (restarts > 10) restarts = restarts.toString().red 292 | 293 | var status = app.status 294 | if (status === 'ON') status = status.green 295 | if (status === 'OFF') status = status.magenta 296 | if (status === 'BROKEN' || status === 'ERROR') status = status.red 297 | 298 | var uptime = app.uptime 299 | if (uptime) uptime = uptime.cyan 300 | 301 | return [ 302 | app.name.yellow, 303 | status, 304 | app.port, 305 | restarts || '', 306 | uptime || '', 307 | app.memory || '', 308 | app.cpu || '' 309 | ] 310 | } 311 | 312 | function exit () { 313 | warn('invalid command arguments') 314 | process.exit(1) 315 | } 316 | -------------------------------------------------------------------------------- /lib/conf.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | module.exports = { 3 | path: process.env.POD_CONF || ((process.env.HOME || os.homedir()) + '/.podrc'), 4 | webId: 'pod-web-service' 5 | } -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | var ERRORS = module.exports = { 2 | EXISTS: 'an app with the name ' + '{{appname}}'.yellow + ' already exists.', 3 | NOT_FOUND: 'app ' + '{{appname}}'.yellow + ' does not exist.', 4 | NO_SCRIPT: 'cannot locate main script for ' + '{{appname}}'.yellow + ' ({{script}})'.grey, 5 | NOT_RUNNING: '{{appname}}'.yellow + ' is not running.', 6 | WEB: 'You cannot remove the web interface!', 7 | RUNNING: '{{appname}}'.yellow + 'is already running' 8 | } 9 | 10 | for (var errKey in ERRORS) { 11 | ERRORS[errKey] = { 12 | msg: ERRORS[errKey], 13 | code: errKey 14 | } 15 | } -------------------------------------------------------------------------------- /lib/formatter.js: -------------------------------------------------------------------------------- 1 | exports.format = formatAppInfo 2 | 3 | function formatAppInfo (app) { 4 | app.uptime = null 5 | var l = (app.instances && app.instances.length) || 0, 6 | instances = l > 1 ? (' (' + l + ')') : '', 7 | name = app.name + instances, 8 | port = app.port || '????', 9 | status = app.broken 10 | ? 'BROKEN' 11 | : app.instances 12 | ? app.instances[0].pm2_env.status === 'errored' 13 | ? 'ERROR' 14 | : 'ON' 15 | : 'OFF' 16 | if (l) { 17 | app.uptime = Date.now() - app.instances[0].pm2_env.pm_uptime 18 | var restarts = countTotalRestarts(app.instances), 19 | uptime = formatTime(app.uptime), 20 | memory = formatMemory(app.instances), 21 | cpu = formatCPU(app.instances) 22 | } 23 | return { 24 | name: name, 25 | port: port, 26 | broken: app.broken, 27 | status: status, 28 | restarts: restarts, 29 | uptime: uptime || null, 30 | memory: memory || null, 31 | cpu: cpu || null, 32 | instanceCount: l, 33 | raw: app 34 | } 35 | } 36 | 37 | function countTotalRestarts (instances) { 38 | var restarts = 0 39 | instances.forEach(function (ins) { 40 | restarts += ins.pm2_env.restart_time 41 | }) 42 | return restarts 43 | } 44 | 45 | function formatTime (uptime) { 46 | var sec_num = Math.floor(uptime / 1000), 47 | days = Math.floor(sec_num / 86400), 48 | hours = Math.floor(sec_num / 3600) % 24, 49 | minutes = Math.floor(sec_num / 60) % 60, 50 | seconds = sec_num % 60, 51 | ret = [] 52 | if (hours < 10) hours = "0" + hours 53 | if (minutes < 10) minutes = "0" + minutes 54 | if (seconds < 10) seconds = "0" + seconds 55 | ret.push(hours, minutes, seconds) 56 | return (days ? days + 'd ' : '') + ret.join(':') 57 | } 58 | 59 | function formatMemory (instances) { 60 | var mem = 0, 61 | mb = 1048576 62 | instances.forEach(function (ins) { 63 | mem += ins.monit.memory 64 | }) 65 | if (mem > mb) { 66 | return (mem / mb).toFixed(2) + ' mb' 67 | } else { 68 | return (mem / 1024).toFixed(2) + ' kb' 69 | } 70 | } 71 | 72 | function formatCPU (instances) { 73 | var total = 0 74 | instances.forEach(function (ins) { 75 | total += ins.monit.cpu 76 | }) 77 | return total.toFixed(2) + '%' 78 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pod", 3 | "version": "0.9.1", 4 | "preferGlobal": true, 5 | "author": { 6 | "name": "Evan You", 7 | "email": "yyx990803@gmail.com" 8 | }, 9 | "dependencies": { 10 | "pm2": "^2.4.5", 11 | "parse-github-url": "^1.0.0", 12 | "colors": "^1.1.2", 13 | "async": "^2.3.0", 14 | "serve-favicon":"^2.4.2", 15 | "serve-static":"^1.12.1", 16 | "mkdirp": "^0.5.1", 17 | "body-parser": "^1.17.1", 18 | "cli-table": "^0.3.1", 19 | "basic-auth":"^1.1.0", 20 | "express": "^4.15.2", 21 | "ejs": "^2.5.6", 22 | "debug": "^2.6.3" 23 | }, 24 | "devDependencies": { 25 | "jscoverage": "^0.6.0", 26 | "mocha": "^3.2.0", 27 | "request": "^2.81.0" 28 | }, 29 | "keywords": [ 30 | "cli", 31 | "deployment", 32 | "sysadmin", 33 | "tools" 34 | ], 35 | "repository": { 36 | "type": "git", 37 | "url": "http://github.com/yyx990803/pod.git" 38 | }, 39 | "bin": { 40 | "pod": "./bin/pod", 41 | "pm2": "./node_modules/pm2/bin/pm2" 42 | }, 43 | "engines": { 44 | "node": ">= 0.8.x" 45 | }, 46 | "main": "lib/api.js", 47 | "description": "Super simple Node.js deployment tool", 48 | "readme": "Pod simplifies the workflow of setting up, updating and managing multiple Node.js apps on a single Linux server.", 49 | "readmeFilename": "README.md", 50 | "scripts": { 51 | "api": "mocha test/api.js --reporter spec --slow 1250", 52 | "cli": "bash test/cli.sh", 53 | "test": "mocha test/api.js -r jscoverage -R spec --slow 1250 --timeout 5000 && bash test/cli.sh" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/api.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'), 2 | fs = require('fs'), 3 | path = require('path'), 4 | http = require('http'), 5 | exec = require('child_process').exec, 6 | request = require('request') 7 | 8 | let temp = path.resolve(__dirname, '../temp'), 9 | root = temp + '/root', 10 | appsDir = root + '/apps', 11 | reposDir = root + '/repos', 12 | testConfPath = temp + '/.podrc', 13 | testConf = fs.readFileSync(path.resolve(__dirname, 'fixtures/.podrc'), 'utf-8'), 14 | stubScript = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'), 15 | podhookStub = fs.readFileSync(path.resolve(__dirname, 'fixtures/.podhook'), 'utf-8'), 16 | testPort = process.env.PORT || 18080 17 | 18 | process.env.POD_CONF = testConfPath 19 | process.on('exit', function () { 20 | delete process.env.POD_CONF 21 | }) 22 | 23 | let pod; 24 | // setup ---------------------------------------------------------------------- 25 | 26 | before(function (done) { 27 | if (process.platform === 'darwin') { 28 | // kill the pm2 daemon first. 29 | // the daemon would malfunction if the Mac went to sleep mode. 30 | exec('./node_modules/pm2/bin/pm2 kill', function (err) { 31 | if (!err) { 32 | setup(done) 33 | } else { 34 | done(err) 35 | } 36 | }) 37 | } else { 38 | setup(done) 39 | } 40 | }) 41 | 42 | function setup(done) { 43 | exec('rm -rf ' + temp, function (err) { 44 | if (err) return done(err) 45 | fs.mkdirSync(temp) 46 | fs.writeFileSync(testConfPath, testConf.replace('{{root}}', root)) 47 | pod = require('../lib/api') 48 | pod.once('ready', done) 49 | }) 50 | } 51 | 52 | // tests ---------------------------------------------------------------------- 53 | 54 | describe('API', function () { 55 | 56 | describe('.reloadConfig', function () { 57 | 58 | it('should reload the conf', function () { 59 | var modified = JSON.parse(fs.readFileSync(testConfPath, 'utf-8')) 60 | modified.default_script = 'app.js' 61 | fs.writeFileSync(testConfPath, JSON.stringify(modified)) 62 | var newConf = pod.reloadConfig() 63 | assert.deepEqual(newConf, modified) 64 | }) 65 | 66 | }) 67 | 68 | describe('.createApp( appname, [options,] callback )', function () { 69 | 70 | it('should complete without error and invoke callback', function (done) { 71 | pod.createApp( 72 | 'test', 73 | { 74 | port: testPort, 75 | instances: 2 76 | }, 77 | function (err, msgs, appInfo) { 78 | if (err) return done(err) 79 | assert.ok(appInfo, 'callback should receive appInfo object') 80 | assert.equal(msgs.length, 4, 'should return 4 messages') 81 | assert.equal(appInfo.config.port, testPort, 'options should be written to app config') 82 | done() 83 | } 84 | ) 85 | }) 86 | 87 | it('should update the config with app\'s entry', function () { 88 | var config = pod.getConfig() 89 | assert.ok(config.apps.test) 90 | }) 91 | 92 | it('should create the app\'s directories', function () { 93 | assert.ok(fs.existsSync(appsDir + '/test'), 'should created working copy') 94 | assert.ok(fs.existsSync(reposDir + '/test.git'), 'should created git repo') 95 | }) 96 | 97 | it('should return error if app with that name already exists', function (done) { 98 | pod.createApp('test', function (err) { 99 | assert.ok(err && err.code === 'EXISTS') 100 | done() 101 | }) 102 | }) 103 | 104 | it('should also work without the optional options', function (done) { 105 | pod.createApp('test2', function (err, msgs, appInfo) { 106 | if (err) return done(err) 107 | assert.ok(appInfo, 'callback should receive appInfo object') 108 | assert.equal(msgs.length, 4, 'should return 4 messages') 109 | done() 110 | }) 111 | }) 112 | 113 | }) 114 | 115 | describe('.startApp( appname, callback )', function () { 116 | 117 | it('should get an error if cannot locate main script', function (done) { 118 | pod.startApp('test', function (err) { 119 | assert.ok(err && err.code === 'NO_SCRIPT', 'should get no script error') 120 | done() 121 | }) 122 | }) 123 | 124 | it('should complete without error and invoke callback', function (done) { 125 | var script = stubScript.replace('{{port}}', testPort - 1) 126 | fs.writeFileSync(appsDir + '/test/app.js', script) 127 | pod.startApp('test', done) 128 | }) 129 | 130 | it('should give back message if app is already running', function (done) { 131 | pod.startApp('test', function (err, msg) { 132 | assert.ok(!err, 'should get no error') 133 | assert.ok(/already\srunning/.test(msg), 'should receive correct message') 134 | done() 135 | }) 136 | }) 137 | 138 | it('should accept http request on port ' + testPort, function (done) { 139 | expectWorkingPort(testPort, done) 140 | }) 141 | 142 | it('should return error if app does not exist', function (done) { 143 | pod.startApp('doesnotexist', function (err) { 144 | assert.ok(err && err.code === 'NOT_FOUND') 145 | done() 146 | }) 147 | }) 148 | 149 | }) 150 | 151 | describe('.stopApp( appname, callback )', function () { 152 | 153 | it('should stop the app', function (done) { 154 | pod.stopApp('test', function (err, msg) { 155 | if (err) return done(err) 156 | assert.ok(/stopped/.test(msg)) 157 | done() 158 | }) 159 | }) 160 | 161 | it('should no longer be using port ' + testPort, function (done) { 162 | expectBadPort(testPort, done) 163 | }) 164 | 165 | it('should return error if app does not exist', function (done) { 166 | pod.stopApp('doesnotexist', function (err) { 167 | assert.ok(err && err.code === 'NOT_FOUND') 168 | done() 169 | }) 170 | }) 171 | 172 | it('should return correct msg if app is not running', function (done) { 173 | pod.stopApp('test', function (err, msg) { 174 | assert.ok(!err) 175 | assert.ok(/not running/.test(msg)) 176 | done() 177 | }) 178 | }) 179 | 180 | }) 181 | 182 | describe('.startAllApps( callback )', function () { 183 | 184 | before(function () { 185 | var script = stubScript.replace('{{port}}', testPort + 1) 186 | fs.writeFileSync(appsDir + '/test2/app.js', script) 187 | }) 188 | 189 | it('should start all apps', function (done) { 190 | pod.startAllApps(function (err, msgs) { 191 | if (err) return done(err) 192 | assert.ok(Array.isArray(msgs), 'should get an array of messages') 193 | assert.equal(msgs.length, 2, 'should get two messages') 194 | done() 195 | }) 196 | }) 197 | 198 | it('should accept http request on both ports', function (done) { 199 | expectWorkingPort(testPort, function () { 200 | expectWorkingPort(testPort + 1, done) 201 | }) 202 | }) 203 | 204 | }) 205 | 206 | describe('.stopAllApps( callback )', function () { 207 | 208 | it('should not get an error', function (done) { 209 | if (pod) { 210 | pod.stopAllApps(function (err, msgs) { 211 | if (err) return done(err) 212 | assert.ok(Array.isArray(msgs), 'should get an array of messages') 213 | assert.equal(msgs.length, 2, 'should get two messages') 214 | done() 215 | }) 216 | } 217 | }) 218 | 219 | it('should no longer be using the two ports', function (done) { 220 | expectBadPort(testPort, function () { 221 | expectBadPort(testPort + 1, done) 222 | }) 223 | }) 224 | 225 | }) 226 | 227 | describe('.listApps( callback )', function () { 228 | 229 | var appsResult 230 | 231 | before(function (done) { 232 | pod.createApp('test3', function () { 233 | exec('rm -rf ' + appsDir + '/test3', function () { 234 | pod.startApp('test', done) 235 | }) 236 | }) 237 | }) 238 | 239 | it('should provide a list of apps\' info', function (done) { 240 | pod.listApps(function (err, apps) { 241 | if (err) return done(err) 242 | assert.equal(apps.length, 3, 'should get three apps') 243 | appsResult = apps 244 | done() 245 | }) 246 | }) 247 | 248 | it('should contain correct app running status', function () { 249 | appsResult.forEach(function (app) { 250 | if (app.name === 'test') { 251 | assert.ok(app.instances, 'test should be on') 252 | } 253 | if (app.name === 'test2') { 254 | assert.ok(!app.instances, 'test2 should be off') 255 | } 256 | }) 257 | }) 258 | 259 | it('should list broken apps', function () { 260 | appsResult.forEach(function (app) { 261 | if (app.name === 'test3') { 262 | assert.ok(app.broken) 263 | } 264 | }) 265 | }) 266 | 267 | after(function (done) { 268 | pod.removeApp('test3', done) 269 | }) 270 | 271 | }) 272 | 273 | describe('.restartApp( appname, callback )', function () { 274 | 275 | var beforeRestartStamp 276 | 277 | it('should restart a running app without error', function (done) { 278 | beforeRestartStamp = Date.now() 279 | pod.restartApp('test', done) 280 | }) 281 | 282 | it('should have indeed restarted the process', function (done) { 283 | expectRestart(testPort, beforeRestartStamp, done) 284 | }) 285 | 286 | it('should get an error trying to restart a non-running app', function (done) { 287 | pod.restartApp('test2', function (err) { 288 | assert.ok(err && err.code === 'NOT_RUNNING') 289 | done() 290 | }) 291 | }) 292 | 293 | it('should return error if app does not exist', function (done) { 294 | pod.restartApp('doesnotexist', function (err) { 295 | assert.ok(err && err.code === 'NOT_FOUND') 296 | done() 297 | }) 298 | }) 299 | 300 | }) 301 | 302 | describe('.restartAllApps()', function () { 303 | 304 | var beforeRestartStamp 305 | 306 | it('should stop all running instances', function (done) { 307 | beforeRestartStamp = Date.now() 308 | pod.restartAllApps(function (err, msgs) { 309 | if (err) return done(err) 310 | assert.ok(Array.isArray(msgs), 'should get an array of messages') 311 | assert.equal(msgs.length, 2, 'should get 2 messages (test has 2 instances)') 312 | done() 313 | }) 314 | }) 315 | 316 | it('should have indeed restarted the running process', function (done) { 317 | expectRestart(testPort, beforeRestartStamp, done) 318 | }) 319 | 320 | it('should not start the non-running app', function (done) { 321 | expectBadPort(testPort + 1, done) 322 | }) 323 | 324 | }) 325 | 326 | describe('.removeApp( appname, callback )', function () { 327 | 328 | var app 329 | 330 | before(function (done) { 331 | app = pod.getAppInfo('test') 332 | pod.removeApp('test', function (err) { 333 | if (err) return done(err) 334 | done() 335 | }) 336 | }) 337 | 338 | it('should have deleted the app from config', function () { 339 | var config = pod.getConfig() 340 | assert.ok(!('test' in config.apps), 'test should no longer be in apps') 341 | }) 342 | 343 | it('should have removed all the app files', function () { 344 | assert.ok(!fs.existsSync(app.workPath), 'working copy') 345 | assert.ok(!fs.existsSync(app.repoPath), 'git repo') 346 | }) 347 | 348 | it('should have stopped the deleted app\'s process', function (done) { 349 | expectBadPort(testPort, done) 350 | }) 351 | 352 | it('should return error if app does not exist', function (done) { 353 | pod.removeApp('doesnotexist', function (err) { 354 | assert.ok(err && err.code === 'NOT_FOUND') 355 | done() 356 | }) 357 | }) 358 | 359 | }) 360 | 361 | describe('prune()', function () { 362 | 363 | var files = [ 364 | root + '/prunefile', 365 | appsDir + '/prunefile', 366 | reposDir + '/prunefile' 367 | ] 368 | 369 | var dirs = [ 370 | root + '/prunedir', 371 | appsDir + '/prunedir', 372 | reposDir + '/prunedir' 373 | ] 374 | 375 | before(function () { 376 | files.forEach(function (f) { 377 | fs.writeFileSync(f) 378 | }) 379 | dirs.forEach(function (d) { 380 | fs.mkdirSync(d) 381 | }) 382 | }) 383 | 384 | it('should remove all extraneous file and directories', function () { 385 | pod.prune(function (err, msg) { 386 | assert.ok(!err) 387 | var fcount = msg.match(/prunefile/g).length, 388 | dcount = msg.match(/prunedir/g).length 389 | assert.equal(fcount, 3) 390 | assert.equal(dcount, 3) 391 | files.forEach(function (f) { 392 | assert.ok(!fs.existsSync(f)) 393 | }) 394 | dirs.forEach(function (d) { 395 | assert.ok(!fs.existsSync(d)) 396 | }) 397 | }) 398 | }) 399 | 400 | }) 401 | 402 | describe('updateHooks()', function () { 403 | 404 | it('should update the hook to the current template', function (done) { 405 | var app = pod.getAppInfo('test2'), 406 | hookPath = app.repoPath + '/hooks/post-receive', 407 | template = fs.readFileSync(__dirname + '/../hooks/post-receive', 'utf-8'), 408 | expected = template 409 | .replace(/\{\{pod_dir\}\}/g, root) 410 | .replace(/\{\{app\}\}/g, app.name) 411 | fs.writeFileSync(hookPath, '123', 'utf-8') 412 | pod.updateHooks(function (err) { 413 | assert.ok(!err) 414 | var hook = fs.readFileSync(hookPath, 'utf-8') 415 | assert.strictEqual(hook, expected) 416 | done() 417 | }) 418 | }) 419 | 420 | }) 421 | 422 | }) 423 | 424 | describe('git push', function () { 425 | 426 | var app, git, beforeRestartStamp 427 | 428 | before(function (done) { 429 | app = pod.getAppInfo('test2') 430 | git = 'git' + 431 | ' --git-dir=' + app.workPath + '/.git' + 432 | ' --work-tree=' + app.workPath 433 | 434 | // add custom hook 435 | fs.writeFileSync(app.workPath + '/.podhook', podhookStub) 436 | 437 | // modify git post-receive hook for test 438 | var hookPath = app.repoPath + '/hooks/post-receive', 439 | hook = fs.readFileSync(hookPath, 'utf-8').replace(/^pod\s/g, 'POD_CONF=' + testConfPath + ' pod ') 440 | fs.writeFileSync(hookPath, hook) 441 | 442 | exec( 443 | git + ' add ' + app.workPath + '; ' + 444 | git + ' commit -m \'test\'', 445 | done 446 | ) 447 | }) 448 | 449 | it('shoud complete without error', function (done) { 450 | beforeRestartStamp = Date.now() 451 | exec(git + ' push origin master', done) 452 | }) 453 | 454 | it('should have restarted the app', function (done) { 455 | expectRestart(testPort + 1, beforeRestartStamp, done) 456 | }) 457 | 458 | it('should have executed the custom hook', function () { 459 | assert.ok(fs.existsSync(app.workPath + '/testfile')) 460 | }) 461 | 462 | it('should reset working tree if podhook exits with code other than 0', function (done) { 463 | 464 | var commit, 465 | clonePath = root + '/clone', 466 | cloneGit = 'git' + 467 | ' --git-dir=' + clonePath + '/.git' + 468 | ' --work-tree=' + clonePath 469 | 470 | exec('cp -r ' + app.workPath + ' ' + clonePath, function (err) { 471 | if (err) return done(err) 472 | exec(git + ' log -1 | awk \'NR==1 {print $2}\'', function (err, cmt) { 473 | if (err) return done(err) 474 | commit = cmt 475 | modifyHook() 476 | }) 477 | }) 478 | 479 | function modifyHook() { 480 | // modify hook in a different copy of the repo 481 | // and push it. 482 | fs.writeFileSync(clonePath + '/.podhook', 'touch testfile2; exit 1') 483 | exec( 484 | cloneGit + ' add ' + clonePath + '; ' + 485 | cloneGit + ' commit -m \'test2\'; ' + 486 | cloneGit + ' push origin master', 487 | function (err) { 488 | if (err) return done(err) 489 | checkCommit() 490 | } 491 | ) 492 | } 493 | 494 | function checkCommit() { 495 | exec(git + ' log -1 | awk \'NR==1 {print $2}\'', function (err, cmt) { 496 | if (err) return done(err) 497 | // make sure the hook is actually executed 498 | assert.ok(fs.existsSync(app.workPath + '/testfile2')) 499 | // the restart should have failed 500 | // and the working copy should have been reverted 501 | // to the old commit 502 | assert.equal(cmt, commit) 503 | done() 504 | }) 505 | } 506 | 507 | }) 508 | 509 | }) 510 | 511 | describe('web interface', function () { 512 | 513 | var webInterfaceId = 'pod-web-service' 514 | 515 | it('should prevent user from deleting it', function (done) { 516 | pod.removeApp(webInterfaceId, function (err) { 517 | assert.equal(err.code, 'WEB') 518 | done() 519 | }) 520 | }) 521 | 522 | it('should start with no problem', function (done) { 523 | pod.startApp(webInterfaceId, function (err, msg) { 524 | if (err) return done(err) 525 | assert.ok(/pod-web-service.*running.*19999/.test(msg)) 526 | done() 527 | }) 528 | }) 529 | 530 | it('should require auth at / and /json', function (done) { 531 | expectWorkingPort(19999, next, { 532 | code: 401, 533 | abort: true, 534 | delay: 300 535 | }) 536 | 537 | function next() { 538 | expectWorkingPort(19999, done, { 539 | path: '/json', 540 | code: 401, 541 | abort: true, 542 | delay: 0 543 | }) 544 | } 545 | }) 546 | 547 | it('should return html at /', function (done) { 548 | expectWorkingPort(19999, done, { 549 | auth: 'admin:admin@', 550 | delay: 0, 551 | expect: function (res) { 552 | assert.ok(/ul id="apps"/.test(res)) 553 | assert.ok(/test2/.test(res)) 554 | } 555 | }) 556 | }) 557 | 558 | it('should return json at /json', function (done) { 559 | expectWorkingPort(19999, done, { 560 | path: '/json', 561 | auth: 'admin:admin@', 562 | delay: 0, 563 | expect: function (res) { 564 | var json 565 | try { 566 | json = JSON.parse(res) 567 | } catch (e) { 568 | done(e) 569 | } 570 | assert.equal(json.length, 1) 571 | assert.equal(json[0].name, 'test2') 572 | } 573 | }) 574 | }) 575 | 576 | }) 577 | 578 | describe('remote app', function () { 579 | 580 | var repoPath = temp + '/remote-test.git', 581 | workPath = temp + '/remote-test', 582 | appPath = appsDir + '/remote-test', 583 | port = testPort + 2, 584 | git = 'git --git-dir=' + workPath + '/.git --work-tree=' + workPath 585 | 586 | before(function (done) { 587 | exec('git --git-dir=' + repoPath + ' --bare init', function () { 588 | exec('git clone ' + repoPath + ' ' + workPath, function () { 589 | fs.writeFileSync(workPath + '/app.js', stubScript.replace('{{port}}', port)) 590 | done() 591 | }) 592 | }) 593 | }) 594 | 595 | it('should create a remote app', function (done) { 596 | pod.createApp('remote-test', { 597 | remote: repoPath 598 | }, function (err, msg) { 599 | assert.ok(!err) 600 | assert.equal(msg.length, 3) 601 | assert.ok(/remote app/.test(msg[1])) 602 | assert.ok(msg[2].indexOf(repoPath) > 0) 603 | assert.ok(fs.existsSync(appPath)) 604 | exec( 605 | git + ' add app.js; ' + 606 | git + ' commit -m "test"; ' + 607 | git + ' push origin master', 608 | done 609 | ) 610 | }) 611 | }) 612 | 613 | it('should refuse webhook if branch doesn\'t match', function (done) { 614 | request({ 615 | url: 'http://localhost:19999/hooks/remote-test', 616 | method: 'POST', 617 | form: { 618 | ref: 'refs/heads/test', 619 | head_commit: { 620 | message: '123' 621 | }, 622 | repository: { 623 | url: repoPath 624 | } 625 | } 626 | }, function (err) { 627 | if (err) return done(err) 628 | setTimeout(function () { 629 | assert.ok(!fs.existsSync(appPath + '/app.js')) 630 | done() 631 | }, 300) 632 | }) 633 | }) 634 | 635 | it('should refuse webhook if repo url doesn\'t match', function (done) { 636 | request({ 637 | url: 'http://localhost:19999/hooks/remote-test', 638 | method: 'POST', 639 | form: { 640 | ref: 'refs/heads/master', 641 | head_commit: { 642 | message: '123' 643 | }, 644 | repository: { 645 | url: 'lolwut' 646 | } 647 | } 648 | }, function (err) { 649 | if (err) return done(err) 650 | setTimeout(function () { 651 | assert.ok(!fs.existsSync(appPath + '/app.js')) 652 | done() 653 | }, 300) 654 | }) 655 | }) 656 | 657 | it('should skip if head commit message contains [pod skip]', function (done) { 658 | request({ 659 | url: 'http://localhost:19999/hooks/remote-test', 660 | method: 'POST', 661 | form: { 662 | ref: 'refs/heads/master', 663 | head_commit: { 664 | message: '[pod skip]' 665 | }, 666 | repository: { 667 | url: repoPath 668 | } 669 | } 670 | }, function (err) { 671 | if (err) return done(err) 672 | setTimeout(function () { 673 | assert.ok(!fs.existsSync(appPath + '/app.js')) 674 | done() 675 | }, 300) 676 | }) 677 | }) 678 | 679 | it('should fetch and run if all requirements are met', function (done) { 680 | request({ 681 | url: 'http://localhost:19999/hooks/remote-test', 682 | method: 'POST', 683 | form: { 684 | ref: 'refs/heads/master', 685 | head_commit: { 686 | message: '123' 687 | }, 688 | repository: { 689 | url: repoPath 690 | } 691 | } 692 | }, function (err) { 693 | if (err) return done(err) 694 | setTimeout(function () { 695 | assert.ok(fs.existsSync(appPath + '/app.js')) 696 | expectWorkingPort(port, done, { delay: 1000 }) 697 | }, 300) 698 | }) 699 | }) 700 | 701 | it('should return 200 if request is a webhook ping', function (done) { 702 | request({ 703 | url: 'http://localhost:19999/hooks/remote-test', 704 | method: 'POST', 705 | headers: { 706 | 'X-Github-Event': 'ping' 707 | }, 708 | form: { 709 | repository: { 710 | url: repoPath 711 | } 712 | } 713 | }, function (err, res) { 714 | if (err) return done(err) 715 | assert.equal(res.statusCode, 200) 716 | done() 717 | }) 718 | }) 719 | 720 | it('should return 500 if request is a webhook ping but path is wrong', function (done) { 721 | request({ 722 | url: 'http://localhost:19999/hooks/remote-test', 723 | method: 'POST', 724 | headers: { 725 | 'X-Github-Event': 'ping' 726 | }, 727 | form: { 728 | repository: { 729 | url: 'lolwut' 730 | } 731 | } 732 | }, function (err, res) { 733 | if (err) return done(err) 734 | assert.equal(res.statusCode, 500) 735 | done() 736 | }) 737 | }) 738 | 739 | }) 740 | 741 | // clean up ------------------------------------------------------------------- 742 | 743 | after(function (done) { 744 | pod.stopAllApps(function (err) { 745 | if (err) return done(err) 746 | fs.rmdirSync(temp); 747 | exec('pm2 kill', done) 748 | }) 749 | }) 750 | 751 | // helpers -------------------------------------------------------------------- 752 | 753 | function expectRestart(port, beforeRestartStamp, done) { 754 | setTimeout(function () { 755 | request('http://localhost:' + port, function (err, res, body) { 756 | if (err) return done(err) 757 | assert.equal(res.statusCode, 200) 758 | var restartStamp = body.match(/\((\d+)\)/)[1] 759 | restartStamp = parseInt(restartStamp, 10) 760 | assert.ok(restartStamp > beforeRestartStamp) 761 | done() 762 | }) 763 | }, 300) 764 | } 765 | 766 | function expectWorkingPort(port, done, options) { 767 | options = options || {} 768 | setTimeout(function () { 769 | request('http://' + (options.auth || '') + 'localhost:' + port + (options.path || ''), function (err, res, body) { 770 | if (err) return done(err) 771 | assert.equal(res.statusCode, options.code || 200) 772 | if (options.abort) return done() 773 | if (options.expect) { 774 | options.expect(body) 775 | } else { 776 | assert.ok(/ok!/.test(body)) 777 | } 778 | done() 779 | }) 780 | }, options.delay || 300) // small interval to make sure it has finished 781 | } 782 | 783 | function expectBadPort(port, done) { 784 | request({ 785 | url: 'http://localhost:' + port, 786 | timeout: 500 787 | }, function (err, res, body) { 788 | if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.code === 'ESOCKETTIMEDOUT') { 789 | return done() 790 | } 791 | if (!err && body) { 792 | return done(new Error('should not get data back')) 793 | } 794 | done(err) 795 | }) 796 | } 797 | -------------------------------------------------------------------------------- /test/cli.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Test Steup ================================================================== 4 | 5 | node="`type -P node`" 6 | nodeVersion="`$node -v`" 7 | pod="`type -P node` `pwd`/bin/pod" 8 | pm2="`type -P node` `pwd`/node_modules/pm2/bin/pm2" 9 | conf="`pwd`/temp/conf/.podrc" 10 | err="`pwd`/temp/error" 11 | 12 | function fail { 13 | echo -e "\033[31m ✘ $1\033[0m" 14 | echo -e " \033[30;1m$(<$err)" 15 | echo 16 | exit 1 17 | } 18 | 19 | function success { 20 | echo -e "\033[32m ✔ \033[30;1m$1\033[0m" 21 | } 22 | 23 | function spec { 24 | [ $? -eq 0 ] || fail "$1" 25 | success "$1" 26 | } 27 | 28 | function ispec { 29 | [ $? -eq 1 ] || fail "$1" 30 | success "$1" 31 | } 32 | 33 | $node -e "var os = require('os'); console.log('> arch : %s\n> platform : %s\n> release : %s\n> type : %s\n> mem : %d', os.arch(), os.platform(), os.release(), os.type(), os.totalmem())" 34 | 35 | echo 36 | echo " CLI" 37 | 38 | # Reset Env =================================================================== 39 | 40 | # kill daemon, clean temp 41 | $pm2 kill > /dev/null 42 | rm -rf `pwd`/temp 43 | mkdir `pwd`/temp 44 | 45 | # First time use ============================================================== 46 | 47 | echo " First time use" 48 | 49 | POD_CONF=$conf POD_ROOT_DIR=temp/files TEST=true $pod >/dev/null 2>$err 50 | spec "should initialize with no error" 51 | 52 | msg="should create config file with correct root path" 53 | OUT=`cat \`pwd\`/temp/conf/.podrc 2>\`pwd\`/temp/error | grep \`pwd\`/temp/files | wc -l` 54 | [ $OUT -eq 1 ] || fail "$msg" 55 | success "$msg" 56 | 57 | ls `pwd`/temp/files >/dev/null 2>$err 58 | spec "should create root dir" 59 | 60 | ls `pwd`/temp/files/apps >/dev/null 2>$err 61 | spec "should create apps dir" 62 | 63 | ls `pwd`/temp/files/repos >/dev/null 2>$err 64 | spec "should create repos dir" 65 | 66 | # Commands ==================================================================== 67 | 68 | echo " Commands" 69 | 70 | msg="create" 71 | OUT=`POD_CONF=$conf $pod create test 2>$err | wc -l` 72 | [ $OUT -eq 4 ] || fail "$msg" 73 | success "$msg" 74 | 75 | msg="start" 76 | # app script 77 | cp `pwd`/test/fixtures/cliapp.js `pwd`/temp/files/apps/test/app.js 78 | # add conf 79 | sed s/'"test": {}'/'"test": { "port": 19787 }'/ `pwd`/temp/conf/.podrc > `pwd`/temp/conf/replace 80 | cat `pwd`/temp/conf/replace > `pwd`/temp/conf/.podrc 81 | OUT=`POD_CONF=$conf $pod start test 2>$err | grep 19787 | wc -l` 82 | [ $OUT -eq 1 ] || fail "$msg" 83 | success "$msg" 84 | 85 | msg="restart" 86 | OUT=`POD_CONF=$conf $pod restart test 2>$err | grep "test.*restarted" | wc -l` 87 | [ $OUT -eq 1 ] || fail "$msg" 88 | success "$msg" 89 | 90 | msg="stop" 91 | OUT=`POD_CONF=$conf $pod stop test 2>$err | grep "test.*stopped" | wc -l` 92 | [ $OUT -eq 1 ] || fail "$msg" 93 | success "$msg" 94 | 95 | msg="startall" 96 | # make another app. 97 | POD_CONF=$conf $pod create test2 >/dev/null 2>$err 98 | cp `pwd`/test/fixtures/cliapp.js `pwd`/temp/files/apps/test2/app.js 99 | sed s/'"test2": {}'/'"test2": { "port": 19788 }'/ `pwd`/temp/conf/.podrc > `pwd`/temp/conf/replace 100 | cat `pwd`/temp/conf/replace > `pwd`/temp/conf/.podrc 101 | OUT=`POD_CONF=$conf $pod startall 2>$err | grep "test.*running" | wc -l` 102 | [ $OUT -eq 2 ] || fail "$msg : expect 2 running, got $OUT" 103 | success "$msg" 104 | 105 | msg="restartall" 106 | OUT=`POD_CONF=$conf $pod restartall 2>$err | grep "test.*restarted" | wc -l` 107 | [ $OUT -eq 2 ] || fail "$msg : expect 2 restarts, got $OUT" 108 | success "$msg" 109 | 110 | msg="stopall" 111 | OUT=`POD_CONF=$conf $pod stopall 2>$err | grep "test.*stopped" | wc -l` 112 | [ $OUT -eq 2 ] || fail "$msg : expect 2 stopped, got $OUT" 113 | success "$msg" 114 | 115 | msg="list" 116 | POD_CONF=$conf $pod start test >/dev/null 2>$err 117 | OUT=`POD_CONF=$conf $pod list 2>$err | wc -l` 118 | [ $OUT -eq 6 ] || fail "$msg : expect 6 lines, got $OUT" 119 | OUT=`POD_CONF=$conf $pod list 2>$err | grep ON | wc -l` 120 | [ $OUT -eq 1 ] || fail "$msg : expect 1 ON, got $OUT" 121 | OUT=`POD_CONF=$conf $pod list 2>$err | grep OFF | wc -l` 122 | [ $OUT -eq 1 ] || fail "$msg : expect 1 OFF, got $OUT" 123 | success "$msg" 124 | 125 | msg="rm" 126 | POD_CONF=$conf $pod rm -f test >/dev/null 2>$err 127 | OUT=`ls -l \`pwd\`/temp/files/apps | grep test | wc -l` 128 | [ $OUT -eq 1 ] || fail "$msg : expect 1 app left, got $OUT" 129 | OUT=`ls -l \`pwd\`/temp/files/repos | grep test | wc -l` 130 | [ $OUT -eq 1 ] || fail "$msg : expect 1 repo left, got $OUT" 131 | OUT=`POD_CONF=$conf $pod list 2>$err | wc -l` 132 | [ $OUT -eq 5 ] || fail "$msg : expect 5 lines from list, got $OUT" 133 | success "$msg" 134 | 135 | msg="prune" 136 | touch `pwd`/temp/files/prunefile 137 | mkdir `pwd`/temp/files/prunedir 138 | touch `pwd`/temp/files/apps/prunefile 139 | mkdir `pwd`/temp/files/apps/prunedir 140 | touch `pwd`/temp/files/repos/prunefile 141 | mkdir `pwd`/temp/files/repos/prunedir 142 | POD_CONF=$conf $pod prune >/dev/null 2>$err 143 | OUT=`find \`pwd\`/temp/files -name prunefile | wc -l` 144 | [ $OUT -eq 0 ] || fail "$msg : prunefiles should be removed" 145 | OUT=`find \`pwd\`/temp/files -name prunedir | wc -l` 146 | [ $OUT -eq 0 ] || fail "$msg : prunedirs should be removed" 147 | success "$msg" 148 | 149 | rm -rf `pwd`/temp 150 | unset POD_CONF 151 | unset POD_ROOT_DIR 152 | pm2 kill 153 | echo -------------------------------------------------------------------------------- /test/fixtures/.podhook: -------------------------------------------------------------------------------- 1 | touch testfile -------------------------------------------------------------------------------- /test/fixtures/.podrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": "{{root}}", 3 | "nodeEnv": "development", 4 | "defaultScript": "not-gonna-work.js", 5 | "apps": {}, 6 | "remotes": {} 7 | } -------------------------------------------------------------------------------- /test/fixtures/app.js: -------------------------------------------------------------------------------- 1 | var startTime = Date.now() 2 | require('http').createServer(function (req, res) { 3 | res.end('ok! process started on: (' + startTime + ')' , 'utf-8') 4 | }).listen(process.env.PORT || {{port}}) -------------------------------------------------------------------------------- /test/fixtures/cliapp.js: -------------------------------------------------------------------------------- 1 | var startTime = Date.now() 2 | require('http').createServer(function (req, res) { 3 | res.end('ok! process started on: (' + startTime + ')' , 'utf-8') 4 | }).listen(process.env.PORT) -------------------------------------------------------------------------------- /web/app.js: -------------------------------------------------------------------------------- 1 | const 2 | bodyParser = require('body-parser'); 3 | fs = require('fs'), 4 | path = require('path'), 5 | spawn = require('child_process').spawn, 6 | express = require('express'), 7 | pod = require('../lib/api'), 8 | ghURL = require('parse-github-url'), 9 | app = express(), 10 | // favicon = require('serve-favicon'), 11 | statics = require('serve-static'), 12 | basicAuth = require('basic-auth'); 13 | 14 | // late def, wait until pod is ready 15 | var conf = pod.reloadConfig() 16 | 17 | // middlewares 18 | var reloadConf = function (req, res, next) { 19 | conf = pod.reloadConfig() 20 | next() 21 | } 22 | var auth = function (req, res, next) { 23 | var user = basicAuth(req); 24 | const username = (conf.web.username || 'admin'); 25 | const password = (conf.web.password || 'admin'); 26 | console.log(JSON.stringify(user)) 27 | if (!user || user.name !== username || user.pass !== password) { 28 | res.setHeader('WWW-Authenticate', 'Basic realm=Authorization Required'); 29 | return res.sendStatus(401); 30 | } 31 | next(); 32 | }; 33 | 34 | app.set('views', __dirname + '/views') 35 | app.set('view engine', 'ejs') 36 | //app.use(favicon()) 37 | app.use(reloadConf) 38 | app.use(bodyParser.json()) 39 | app.use(statics(path.join(__dirname, 'static'))) 40 | 41 | 42 | app.get('/', auth, function (req, res) { 43 | pod.listApps(function (err, list) { 44 | if (err) return res.end(err) 45 | return res.render('index', { 46 | apps: list 47 | }) 48 | }) 49 | }) 50 | 51 | app.get('/json', auth, function (req, res) { 52 | pod.listApps(function (err, list) { 53 | if (err) return res.end(err) 54 | res.json(list) 55 | res.end(); 56 | }) 57 | }) 58 | 59 | app.post('/hooks/:appid', function (req, res) { 60 | var appid = req.params.appid, 61 | payload = JSON.stringify(req.body), 62 | app = conf.apps[appid] 63 | 64 | try { 65 | payload = JSON.parse(payload) 66 | } catch (e) { 67 | return res.end(e.toString()) 68 | } 69 | 70 | if (req.get('X-GitHub-Event') === 'ping') { 71 | if (ghURL(payload.repository.git_url).repopath === ghURL(app.remote).repopath) { 72 | return res.status(200).end() 73 | } else { 74 | return res.status(500).end() 75 | } 76 | } 77 | 78 | if (app && verify(req, app, payload)) { 79 | executeHook(appid, app, payload, function () { 80 | res.end() 81 | }) 82 | } else { 83 | res.end() 84 | } 85 | }) 86 | 87 | // listen when API is ready 88 | pod.once('ready', function () { 89 | // load config first 90 | conf = pod.getConfig() 91 | // conditional open up jsonp based on config 92 | if (conf.web.jsonp === true) { 93 | app.get('/jsonp', function (req, res) { 94 | pod.listApps(function (err, list) { 95 | if (err) return res.end(err) 96 | res.jsonp(list) 97 | }) 98 | }) 99 | } 100 | app.listen(process.env.PORT || 19999) 101 | }) 102 | 103 | // Helpers 104 | function verify(req, app, payload) { 105 | // not even a remote app 106 | if (!app.remote) return 107 | // check repo match 108 | 109 | var repo = payload.repository 110 | var repoURL 111 | 112 | if (repo.links && /bitbucket\.org/.test(repo.links.html.href)) { 113 | console.log('\nreceived webhook request from: ' + repo.links.html.href) 114 | 115 | repoURL = repo.links.html.href 116 | } else { 117 | console.log('\nreceived webhook request from: ' + repo.url) 118 | 119 | repoURL = repo.url 120 | } 121 | 122 | if (!repoURL) return 123 | 124 | if (ghURL(repoURL).repopath !== ghURL(app.remote).repopath) { 125 | console.log('aborted.') 126 | return 127 | } 128 | 129 | var commit 130 | 131 | // support bitbucket webhooks payload structure 132 | if (/bitbucket\.org/.test(repoURL)) { 133 | commit = payload.push.changes[0].new 134 | 135 | commit.message = commit.target.message 136 | } else { 137 | // use gitlab's payload structure if detected 138 | commit = payload.head_commit ? payload.head_commit : 139 | payload.commits[payload.commits.length - 1]; 140 | } 141 | 142 | if (!commit) return 143 | 144 | // skip it with [pod skip] message 145 | console.log('commit message: ' + commit.message) 146 | if (/\[pod skip\]/.test(commit.message)) { 147 | console.log('aborted.') 148 | return 149 | } 150 | // check branch match 151 | var ref = commit.name ? commit.name : payload.ref 152 | 153 | if (!ref) return 154 | 155 | var branch = ref.replace('refs/heads/', ''), 156 | expected = app.branch || 'master' 157 | console.log('expected branch: ' + expected + ', got branch: ' + branch) 158 | if (branch !== expected) { 159 | console.log('aborted.') 160 | return 161 | } 162 | return true 163 | } 164 | 165 | function executeHook(appid, app, payload, cb) { 166 | 167 | // set a response timeout to avoid GitHub webhooks 168 | // hanging up due to long build times 169 | var responded = false 170 | function respond(err) { 171 | if (!responded) { 172 | responded = true 173 | cb(err) 174 | } 175 | } 176 | setTimeout(respond, 3000) 177 | 178 | fs.readFile(path.resolve(__dirname, '../hooks/post-receive'), 'utf-8', function (err, template) { 179 | if (err) return respond(err) 180 | var hookPath = conf.root + '/temphook.sh', 181 | hook = template 182 | .replace(/\{\{pod_dir\}\}/g, conf.root) 183 | .replace(/\{\{app\}\}/g, appid) 184 | if (app.branch) { 185 | hook = hook.replace('origin/master', 'origin/' + app.branch) 186 | } 187 | fs.writeFile(hookPath, hook, function (err) { 188 | if (err) return respond(err) 189 | fs.chmod(hookPath, '0777', function (err) { 190 | if (err) return respond(err) 191 | console.log('excuting github webhook for ' + appid + '...') 192 | var child = spawn('bash', [hookPath]) 193 | child.stdout.pipe(process.stdout) 194 | child.stderr.pipe(process.stderr) 195 | child.on('exit', function (code) { 196 | fs.unlink(hookPath, respond) 197 | }) 198 | }) 199 | }) 200 | }) 201 | } 202 | 203 | -------------------------------------------------------------------------------- /web/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /web/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 30px 50px; 3 | font: 14px "Monaco", Helvetica, Arial, sans-serif; 4 | font-weight: 300; 5 | letter-spacing: 1px; 6 | line-height: 1em; 7 | color: #555; 8 | } 9 | 10 | h1 { 11 | font-weight: 300; 12 | font-size: 24px; 13 | margin-bottom: 30px; 14 | text-transform: uppercase; 15 | } 16 | 17 | a { 18 | color: #00B7FF; 19 | } 20 | 21 | .yes { 22 | color: #6c0; 23 | } 24 | 25 | .no { 26 | color: #f57; 27 | } 28 | 29 | h2 { 30 | color: #FF3333; 31 | } 32 | 33 | .service { 34 | display: inline-block; 35 | width: 255px; 36 | } 37 | 38 | #apps { 39 | list-style-type: none; 40 | padding: 0; 41 | line-height: 2em; 42 | } 43 | 44 | #apps li { 45 | padding: 12px 20px; 46 | border: 1px solid #ddd; 47 | border-top: none; 48 | } 49 | 50 | #apps li:first-child { 51 | border-top: 1px solid #ddd; 52 | } 53 | 54 | #apps span { 55 | display: inline-block; 56 | vertical-align: middle; 57 | margin-right: 10px; 58 | } 59 | 60 | #apps .indicator { 61 | width: 12px; 62 | height: 12px; 63 | border-radius: 100%; 64 | background-color: #ccc; 65 | position: relative; 66 | top: 1px; 67 | } 68 | 69 | #apps .status { 70 | width: 25px; 71 | } 72 | 73 | #apps .indicator.ON { 74 | background-color: #6c6; 75 | } 76 | #apps .indicator.BROKEN, #apps .indicator.ERROR { 77 | background-color: #f00; 78 | } 79 | 80 | #apps .status.ON { 81 | color: #6c6; 82 | } 83 | #apps .status.BROKEN, #apps .status.ERROR { 84 | color: #f00; 85 | } 86 | 87 | #apps .port { 88 | width: 100px; 89 | } 90 | 91 | #apps .name { 92 | color: #00B7FF; 93 | font-weight: bold; 94 | text-transform: uppercase; 95 | width: 120px; 96 | } -------------------------------------------------------------------------------- /web/test.js: -------------------------------------------------------------------------------- 1 | var r = require('request') 2 | r({ 3 | url: 'http://localhost:19999/hooks/test3', 4 | method: 'POST', 5 | headers: { 6 | 'User-Agent': 'GitHub Hookshot' 7 | }, 8 | form: { 9 | payload: JSON.stringify({ 10 | ref: 'refs/heads/test', 11 | head_commit: { 12 | message: '123' 13 | }, 14 | repository: { 15 | url: '/Users/yyou/Personal/pod/local/repos/test.git' 16 | } 17 | }) 18 | } 19 | }) -------------------------------------------------------------------------------- /web/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pod Status 5 | 6 | 7 | 8 | 9 | 10 |

pod status

11 |
    12 | <% apps.forEach(function (app) { %> 13 |
  • 14 | <%= app.name %> 15 | 16 | <%= app.status %> 17 | :<%= app.port %> 18 | <% if (app.instanceCount > 0) { %> 19 | instances: <%= app.instanceCount %> | 20 | restarts: <%= app.restarts %> | 21 | uptime: <%= app.uptime %> | 22 | Memory: <%= app.memory %> | 23 | CPU: <%= app.cpu %> 24 | <% } %> 25 |
  • 26 | <% }) %> 27 |
28 | 29 | --------------------------------------------------------------------------------