├── .gitignore ├── LICENSE ├── README.md ├── bin ├── deployer └── deployer.js ├── index.js ├── lib ├── Deployer.js ├── reporters │ ├── SpinnerReporter.js │ ├── StdReporter.js │ └── VoidReporter.js ├── strategies │ └── GitStrategy.js └── utils │ ├── ConnUtils.js │ ├── DeployUtils.js │ ├── HookUtils.js │ └── config.js ├── package.json └── test ├── fixtures └── ssh_server.js ├── integrations └── setup.js └── units └── config.js /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /node_modules 3 | *.log 4 | *.log 5 | *.pid 6 | test/child 7 | *.iml 8 | .idea/** 9 | *.heapsnapshot 10 | *.cpuprofile 11 | .cache-require-paths.json 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 vmarchaud 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 | # DeployerJS 2 | 3 | 4 | Standard - JavaScript Style Guide 5 | 6 | 7 | 8 | 9 | 10 | #### Why Another Deployement Tool ? 11 | 12 | Pure javascript, no binary needed on the local machine, forget about under-the-hood `shellscript` or `rsync`/`ssh` binary spawn. I just want to run this tool everywhere (even under windows, yes, windows) i can run NodeJS. 13 | 14 | # How to use it ? 15 | 16 | ##### CLI Overview 17 | ```bash 18 | deployer update [file] # update remote to latest 19 | deployer rollback [file] [n] # revert to [n]th last 20 | deployer currrent [file] # output current release commit 21 | deployer previous [file] # output previous release commit 22 | deployer execute [file] # execute the given 23 | deployer list [file] # list previous deployements 24 | ``` 25 | 26 | ### CLI options 27 | 28 | You can specify some options when using the CLI : 29 | 30 | - `-d` or `--details` when set to true, a file containing detailed logs of the deployement will be created in the current directory 31 | - `-s [strategy]` or `--strategy [strategy]` choose the strategy used to deploy, [see below for explanation](https://github.com/vmarchaud/deployerjs#deployement-strategy) 32 | - `-r [reporter]` or `--reporter [reporter]` choose the reporter used to show deployement info, [see below for explanation](https://github.com/vmarchaud/deployerjs#deployment-reporter) 33 | 34 | 35 | # Configuration 36 | 37 | DeployerJS is based on the configuration architecture of [PM2](https://github.com/Unitech/pm2), so we represent a remote environnement like this (where `production` is the environnement name) : 38 | 39 | ```javascript 40 | { 41 | "production": { 42 | "user": "totallynotroot", // ssh user 43 | "host": "127.0.0.1:22" // host adress + ssh port 44 | "ref": "origin/master", // remote branch 45 | "repo": "git@github.com:vmarchaud/deployerjs.git", // git repo 46 | "path": "/home/totallynotroot/myprod/", // path where the deployement will be done 47 | "passphrase" : true, // if my rsa key have a passphrase 48 | "post-deploy": "npm install", // will be executed after update on remote system 49 | "post-deploy-local": "touch deploy.done", // will be executed after update on local system 50 | "env" : { // all remote hooks will be executed with this env 51 | "NODE_ENV": "production" 52 | } 53 | } 54 | } 55 | ``` 56 | To list all environnements you have two choices, put the list at the root of the JSON document (like above) or specify `deploy` object that will list them (example here with a `package.json` file) : 57 | ```javascript 58 | { 59 | "name": "myproject", 60 | "version": "0.0.1", 61 | "scripts": { 62 | ... 63 | }, 64 | "dependencies": { 65 | ... 66 | }, 67 | "deploy" : { // here you can define the environnement entries 68 | "staging" : { ... }, // define here your staging environnement 69 | "production" : { ... } // define here you production environnement 70 | } 71 | } 72 | ``` 73 | 74 | Here are the **principal options** you can set in a environnement : 75 | 76 | | Option key | Value Type | Default | Example | Description | 77 | | ---------: | -----------:| -------:| --------:| -----------:| 78 | | user | String | | root | username used to authenticate to the ssh server | 79 | | host | String or Array | | `['localhost:22']` or `localhost` | can contains port of the remote host, array may contains multiple hosts | 80 | | port | String or Number | 22 | `22` or `"22"` | port of the remote ssh server | 81 | | password | Boolean | false | `true` or `false` | set a password to connect | 82 | | privateKey | String | ~/.ssh/id_rsa | a valid path on local system | use a rsa key to connect | 83 | | passphrase | Boolean | false | `true` or `false` | set a passphrase to use the rsa key | 84 | | path | String ||| remote path where the deployement will be done | 85 | | group | String | | `production` | set the group of the environnement (usefull to deploy on a group of servers) | 86 | | path | String ||| remote path where the deployement will be done | 87 | | env | Object || `{ "NODE_ENV": "production" }`| environnement used to execute remote hooks | 88 | | ssh_options | Object | | `{ "agent": "myagent_path" }` | options for ssh see [ssh2 connect doc](https://github.com/mscdex/ssh2#client-methods) | 89 | 90 | ## Hooks 91 | 92 | You can tell deployerjs to execute commands for you, we call them **hooks**, they can be run either on **remote** or **local** system, you just need an entry in the configuration (like the above example), here are the current hooks : 93 | - pre-setup & pre-setup-local 94 | - post-setup & post-setup-local 95 | - post-rollback & post-rollback-local 96 | - post-deploy & post-deploy-local 97 | 98 | 99 | ## Deployement Strategy 100 | 101 | Deployement action (like updating/rollbacking) are done via **Strategy**, the default is the`GitStrategy` that will simply use **git** on the remote server to execute all commands necessary to deploy the code, they use **their own key/value configuration**, so i split their configuration. 102 | 103 | Available strategies (and the corresponding CLI option to use) : 104 | - GitStrategy (`--strategy git`) 105 | 106 | 107 | ### GitStrategy 108 | 109 | #### Configuration 110 | 111 | | Option key | Value Type | Default | Example | Description | 112 | | ---------: | -----------:| -------:| --------:| -----------:| 113 | | ref | String | `origin/master`| any origin and branch separed by slash | Which remote and branch should be used | 114 | | branch | String | `master` | | branch used (override `ref`) | 115 | | origin | String | `origin` | | origin used (override `ref`) | 116 | | repo | String | | `github.com/vmarchaud/deployer` | Git repo URI | 117 | 118 | ## Deployment Reporter 119 | 120 | To display information relative to deployement, we use a `reporter` (just a class with some methods) that will handle display of information. 121 | 122 | By default the `StdReporter` is used, it will just print all data to STDOUT. 123 | 124 | Available reporters (and the corresponding CLI option to use) : 125 | - StdReporter (`--reporter std`) 126 | - SpinnerReporter (`--reporter spinner`) 127 | 128 | # Architecture 129 | 130 | Because you want to know how to fork this and start hacking it : 131 | 132 | **soon™** 133 | 134 | # Relevant documentation 135 | 136 | - [ssh2](https://github.com/mscdex/ssh2) - the module used to connect over a SSH connection to the servers 137 | - [ssh2-streams](https://github.com/mscdex/ssh2-streams/blob/master/SFTPStream.md) - the module used by `ssh2` to implement some protocols (sftp mainly used here). 138 | 139 | ## TODO: 140 | 141 | - More reporters 142 | - More strategies 143 | - Things to remember to fix : 144 | - [ ] git clone without host verified 145 | -------------------------------------------------------------------------------- /bin/deployer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('./deployer.js'); -------------------------------------------------------------------------------- /bin/deployer.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | const Deployer = require('../lib/Deployer') 6 | const deployUtls = require('../lib/utils/DeployUtils') 7 | const pkg = require('../package.json') 8 | const commander = require('commander') 9 | const fs = require('fs') 10 | const path = require('path') 11 | const vm = require('vm') 12 | 13 | commander 14 | .version(pkg.version) 15 | .option('-s, --strategy [git]', 'set the strategy used to balance connection (default to git)', 'git') 16 | .option('-r, --reporter [std]', 'change reporter used to print informations (default to stdout)', 'std') 17 | .option('-d, --details', 'create a detailed report on the filesystem (default to false)', false) 18 | .on('--help', () => { 19 | console.log('') 20 | console.log('-----> DeployerJs CLI Help') 21 | console.log('') 22 | console.log(' Commands:') 23 | console.log(' update [file] update remote to the latest release') 24 | console.log(' rollback [file] [n] revert to [n]th last deployment or 1') 25 | console.log(' curr[ent] [file] output current release commit') 26 | console.log(' prev[ious] [file] output previous release commit') 27 | console.log(' exec [file] execute the given ') 28 | console.log(' list [file] list previous deploy commits') 29 | console.log('') 30 | }) 31 | commander.command('deploy [file]') 32 | .description('deploy the code in your remote environement') 33 | .alias('update') 34 | .action((environement, file) => { 35 | // instanciate api 36 | var deployer = new Deployer(resolveConf(file), { 37 | strategy: commander.strategy, 38 | reporter: commander.reporter 39 | }) 40 | 41 | // select and deploy 42 | deployer.select(environement, (err, servers) => { 43 | if (err) return commander.details ? generateDetails(servers, exit, err) : exit(err) 44 | deployer.deploy(servers, (err) => { 45 | return commander.details ? generateDetails(servers, exit, err) : exit(err) 46 | }) 47 | }) 48 | }) 49 | commander.command('rollback [file] [n]') 50 | .description('rollback the code of your remote environement') 51 | .alias('revert') 52 | .action((environement, file, nbr) => { 53 | if (!nbr && !isNaN(file)) nbr = file 54 | 55 | // instanciate api 56 | var deployer = new Deployer(resolveConf(file), { 57 | strategy: commander.strategy, 58 | reporter: commander.reporter 59 | }) 60 | 61 | // select and deploy 62 | deployer.select(environement, (err, servers) => { 63 | if (err) return commander.details ? generateDetails(servers, exit, err) : exit(err) 64 | deployer.rollback(servers, { rollback: nbr }, (err) => { 65 | return commander.details ? generateDetails(servers, exit, err) : exit(err) 66 | }) 67 | }) 68 | }) 69 | commander.command('clean [file]') 70 | .description('clean the remote enviromment and get the latest version of the code') 71 | .alias('clear') 72 | .action((environement, file) => { 73 | deployUtls.askUser('[CLI] Are you sure you want to proceed a clean install of remote system ? y/n ', (res) => { 74 | if (!res.match(/yes|y/gi)) return exit(new Error('Aborting clean install')) 75 | // instanciate api 76 | var deployer = new Deployer(resolveConf(file), { 77 | strategy: commander.strategy, 78 | reporter: commander.reporter 79 | }) 80 | 81 | // select and deploy 82 | deployer.select(environement, (err, servers) => { 83 | if (err) return commander.details ? generateDetails(servers, exit, err) : exit(err) 84 | deployer.clean(servers, (err) => { 85 | return commander.details ? generateDetails(servers, exit, err) : exit(err) 86 | }) 87 | }) 88 | }) 89 | }) 90 | 91 | commander.parse(process.argv) 92 | 93 | function trimUnicode (string) { 94 | return string.replace(/\\u[a-z0-9]{4}\[([^\\]{0,3})|\\u[a-z0-9]{4}/gmi, '').replace('\b', '\n') 95 | } 96 | 97 | function generateDetails (servers, next, err) { 98 | let reports = { '_CLI_GOT_': err ? err.message || err : 'SUCCESS' } 99 | // only get the details 100 | for (let server in servers) { 101 | reports[server] = servers[server].reporter.details(servers[server]) || [] 102 | reports[server].forEach((report) => { 103 | if (report.stdout) { report.stdout = JSON.parse(trimUnicode(JSON.stringify(report.stdout)).split('\n')) } 104 | if (report.stderr) { 105 | report.stderr = JSON.parse(trimUnicode(JSON.stringify(report.stderr)).split('\n')) 106 | } 107 | }) 108 | } 109 | // try to write it on filesystem 110 | let pwd = path.join(process.env.PWD || process.cwd(), `deployer_details.json`) 111 | fs.writeFile(pwd, JSON.stringify(reports, null, 2), next) 112 | } 113 | 114 | function resolveConf (confPath) { 115 | let file 116 | for (var tmpPath of [confPath, 'ecosystem.json', 'package.json']) { 117 | if (!tmpPath) continue 118 | 119 | tmpPath = path.resolve(tmpPath) 120 | try { 121 | let str = fs.readFileSync(tmpPath) 122 | // run the file if its js or json 123 | if (tmpPath.match(/\.js|\.json/)) { 124 | file = vm.runInThisContext('(' + str + ')') 125 | } else { 126 | file = JSON.parse(str) 127 | } 128 | break 129 | } catch (err) { 130 | console.log(err) 131 | continue 132 | } 133 | } 134 | return !file ? exit(new Error('Cant find any valid file configuration')) : (file.deploy ? file.deploy : file) 135 | } 136 | 137 | function exit (err) { 138 | if (err) { console.log('[CLI] ERROR : %s', err.message || err) } else { console.log('[CLI] SUCCESS') } 139 | process.exit(err ? 1 : 0) 140 | } 141 | 142 | commander.command('*') 143 | .action(commander.outputHelp) 144 | 145 | if (process.argv.length === 2) { 146 | commander.parse(process.argv) 147 | commander.outputHelp() 148 | process.exit(0) 149 | } 150 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.export = require('./lib/Deployer') 2 | -------------------------------------------------------------------------------- /lib/Deployer.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | var async = require('async') 5 | var util = require('util') 6 | 7 | const DeployUtils = require('./utils/DeployUtils') 8 | const ConnUtils = require('./utils/ConnUtils') 9 | const HookUtils = require('./utils/HookUtils') 10 | const config = require('./utils/config') 11 | 12 | module.exports = class Deployer { 13 | 14 | constructor (conf, opts) { 15 | this.envs = typeof (conf) === 'string' ? JSON.parse(conf) : conf 16 | this.groups = {} 17 | opts = opts || {} 18 | if (typeof (this.envs) !== 'object') { 19 | throw new Error('Invalid configuration object') 20 | } 21 | 22 | for (let key in this.envs) { 23 | this.envs[key].name = key 24 | } 25 | 26 | // compute group of each env if defined 27 | for (let key in this.envs) { 28 | let env = this.envs[key] 29 | if (!env.group) continue 30 | 31 | if (!this.groups[env.group]) { this.groups[env.group] = [] } 32 | 33 | this.groups[env.group].push(env) 34 | } 35 | 36 | // resolve global reporter from opts 37 | if (typeof (opts.reporter) === 'string') { 38 | let resolved = config.reporters[opts.reporter.toUpperCase()] 39 | this.Reporter = resolved || config.reporters.STD 40 | } else if (typeof (opts.reporter) === 'function') { 41 | this.Reporter = opts.reporter 42 | } else { 43 | this.Reporter = config.reporters.STD 44 | } 45 | 46 | // resolve global strategy from opts 47 | if (typeof (opts.strategy) === 'string') { 48 | let resolved = config.strategies[opts.strategy.toUpperCase()] 49 | this.Strategy = resolved || config.strategies.GIT 50 | } else if (typeof (opts.strategy) === 'function') { 51 | this.Strategy = opts.strategy 52 | } else { 53 | this.Strategy = config.strategies.GIT 54 | } 55 | } 56 | 57 | /** 58 | * Function called to any remote action, for example ask for credentials like ssh password or private key passphrase 59 | * Must be called before any action since its resolve password / passphrase 60 | * 61 | * @param {String} target A declared environement OR a group of declared environements 62 | * @param {Function} cb(err, servers) Callback called after preparation completed 63 | */ 64 | select (target, opts, cb) { 65 | if (!cb) cb = opts 66 | var servers = DeployUtils.resolveServers(this.envs, this.groups, target) 67 | // an error is returned (malformed config) 68 | if (servers instanceof Error) return cb(servers) 69 | 70 | async.eachLimit(Object.keys(servers), 1, (serverName, next) => { 71 | var server = servers[serverName] 72 | 73 | // if password or passphrase is a boolean (and true), the user must enter credentials for each server 74 | if ((typeof (server.password) === 'boolean' && server.password) || (typeof (server.passphrase) === 'boolean' && server.passphrase)) { 75 | var type = server.password ? 'password' : 'passphrase' 76 | 77 | DeployUtils.askUser(util.format('[DEPLOYER] You need to provide a %s for server %s : ', type, server.name), true, (value) => { 78 | server[type] = value 79 | return next(null) 80 | }) 81 | } else { 82 | return next(null) 83 | } 84 | }, function (err) { 85 | return cb(err, servers) 86 | }) 87 | } 88 | 89 | /** 90 | * Function called to make remote deployement on each remove servers 91 | * 92 | * @param {Object} servers Map with host for key and configuration in value 93 | */ 94 | deploy (servers, opts, cb) { 95 | if (!cb) cb = opts 96 | // instanciate reporter that has been configured 97 | this.reporter = new this.Reporter(servers) 98 | async.each(servers, (server, next) => { 99 | // we will push all data in this field to make a detailed report of all data we got 100 | server.details = [] 101 | server.reporter = this.reporter 102 | 103 | // create ssh connection to the hostname 104 | ConnUtils.create(server.name, server, (err, conn) => { 105 | if (err) return this.reporter.error(server, err, next) 106 | 107 | this.reporter.info(server, 'Verification of remote system') 108 | server.conn = conn 109 | server.strategy = new this.Strategy(server) 110 | 111 | // ensure that the remote system is already setup 112 | DeployUtils.ensureSetup(server, (err) => { 113 | if (err) return this.reporter.error(server, err, next) 114 | 115 | this.reporter.info(server, 'Remote system is ready !') 116 | server.strategy.update((err) => { 117 | ConnUtils.logRemote('update', server, err) 118 | if (err) return this.reporter.error(server, err, next) 119 | 120 | HookUtils.call('post-deploy', server, (err) => { 121 | return err ? this.reporter.error(server, err) 122 | : this.reporter.success(server, 'Remote system updated', next) 123 | }) 124 | }) 125 | }) 126 | }) 127 | }, function (err, results) { 128 | // clean all connections 129 | for (let server in servers) { 130 | if (servers[server].conn) { 131 | servers[server].conn.end() 132 | } 133 | } 134 | return cb(err, results) 135 | }) 136 | } 137 | 138 | /** 139 | * Function called to make rollback on remote environements 140 | * 141 | * @param {Object} servers Map with host for key and configuration in value 142 | */ 143 | rollback (servers, opts, cb) { 144 | if (!cb) cb = opts 145 | 146 | // instanciate reporter that has been configured 147 | this.reporter = new this.Reporter(servers) 148 | 149 | // resolve rollback number 150 | opts.rollback = opts.rollback ? typeof (opts.rollback) !== 'number' 151 | ? parseInt(opts.rollback) || config.rollback : opts.rollback : config.rollback 152 | 153 | async.each(servers, (server, next) => { 154 | // we will push all data in this field to make a detailed report of all data we got 155 | server.details = [] 156 | server.reporter = this.reporter 157 | 158 | // create ssh connection to the hostname 159 | ConnUtils.create(server.name, server, (err, conn) => { 160 | if (err) return this.reporter.error(server, err, next) 161 | 162 | this.reporter.info(server, 'Verification of remote system') 163 | server.conn = conn 164 | server.strategy = new this.Strategy(server) 165 | 166 | // ensure that the remote system is already setup 167 | DeployUtils.ensureSetup(server, (err) => { 168 | ConnUtils.logRemote('rollback', server, err) 169 | if (err) return this.reporter.error(server, err, next) 170 | 171 | this.reporter.info(server, 'Remote system is ready !') 172 | server.strategy.rollback(opts.rollback, (err) => { 173 | if (err) return this.reporter.error(server, err, next) 174 | 175 | HookUtils.call('post-rollback', server, (err) => { 176 | return err ? this.reporter.error(server, err) 177 | : this.reporter.success(server, 'Remote system has been rollback of ' + opts.rollback + ' releases', next) 178 | }) 179 | }) 180 | }) 181 | }) 182 | }, function (err, results) { 183 | // clean all connections 184 | for (let server in servers) { 185 | if (servers[server].conn) { servers[server].conn.end() } 186 | } 187 | return cb(err, results) 188 | }) 189 | } 190 | 191 | /** 192 | * Function called to make remote deployement on each remove servers 193 | * 194 | * @param {Object} servers Map with host for key and configuration in value 195 | */ 196 | clean (servers, opts, cb) { 197 | if (!cb) cb = opts 198 | // instanciate reporter that has been configured 199 | this.reporter = new this.Reporter(servers) 200 | async.each(servers, (server, next) => { 201 | // we will push all data in this field to make a detailed report of all data we got 202 | server.details = [] 203 | server.reporter = this.reporter 204 | 205 | // create ssh connection to the hostname 206 | ConnUtils.create(server.name, server, (err, conn) => { 207 | if (err) return this.reporter.error(server, err, next) 208 | 209 | this.reporter.info(server, 'Cleaning of the remote system') 210 | server.conn = conn 211 | server.strategy = new this.Strategy(server) 212 | 213 | // ensure that the remote system is already setup 214 | DeployUtils.ensureSetup(server, { 215 | force: true 216 | }, (err) => { 217 | if (err) return this.reporter.error(server, err, next) 218 | 219 | this.reporter.info(server, 'Remote system is ready !') 220 | server.strategy.update((err) => { 221 | ConnUtils.logRemote('update', server, err) 222 | if (err) return this.reporter.error(server, err, next) 223 | 224 | HookUtils.call('post-deploy', server, (err) => { 225 | return err ? this.reporter.error(server, err) 226 | : this.reporter.success(server, 'Remote system updated', next) 227 | }) 228 | }) 229 | }) 230 | }) 231 | }, function (err, results) { 232 | // clean all connections 233 | for (let server in servers) { 234 | if (servers[server].conn) { servers[server].conn.end() } 235 | } 236 | return cb(err, results) 237 | }) 238 | } 239 | 240 | } 241 | -------------------------------------------------------------------------------- /lib/reporters/SpinnerReporter.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | var Spinner = require('multispinner') 5 | 6 | module.exports = class SpinnerReporter { 7 | 8 | constructor (entries) { 9 | this._entries = entries 10 | this.spinners = new Spinner(Object.keys(entries), { 11 | postSpace: ' : ', 12 | color: { 13 | 'incomplete': 'gray', 14 | 'success': 'green', 15 | 'error': 'red' 16 | } 17 | }) 18 | 19 | this._details = {} 20 | for (let entry in entries) { 21 | this._details[entry] = [] 22 | } 23 | } 24 | 25 | /** 26 | * Info log, will be printed and added to details 27 | * 28 | * @param {string} entry the entry name 29 | * @param {string} msg the message that will be displayed 30 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 31 | */ 32 | info (entry, msg, cb) { 33 | if (!this._entries[entry]) { return } 34 | this.spinners.updateText(entry, { postText: msg }) 35 | this._details[entry].push(msg) 36 | 37 | return cb ? cb(null, msg) : true 38 | } 39 | 40 | /** 41 | * Error log, will be printed and added to details 42 | * 43 | * @param {string} entry the entry name 44 | * @param {string} msg the error instance that has caused the problem 45 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 46 | */ 47 | error (entry, err, cb) { 48 | if (!this._entries[entry]) { 49 | return 50 | } 51 | this.spinners.updateText(entry, { postText: err.message || err }) 52 | this.spinners.error(entry) 53 | this.spinners.loop() 54 | this._details[entry].push(err.message || err) 55 | 56 | return cb ? cb(err) : true 57 | } 58 | 59 | /** 60 | * Success log, will be printed and added to details 61 | * 62 | * @param {string} entry the entry name 63 | * @param {string} msg the message that will be displayed 64 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 65 | */ 66 | success (entry, msg, cb) { 67 | if (!this._entries[entry]) { 68 | return 69 | } 70 | this.spinners.updateText(entry, { postText: msg || 'Success' }) 71 | this.spinners.success(entry) 72 | this.spinners.loop() 73 | this._details[entry].push(msg) 74 | 75 | return cb ? cb(null, msg) : true 76 | } 77 | 78 | /** 79 | * Fine log, will not be printed but will be added in details 80 | */ 81 | fine (entry, msg, cb) { 82 | if (!this._entries[entry]) { 83 | return 84 | } 85 | this._details[entry].push(msg) 86 | 87 | return cb ? cb(null, msg) : true 88 | } 89 | 90 | /** 91 | * Will return detailed data for entry provided 92 | * 93 | * @param {string} entry the entry name 94 | */ 95 | details (entry) { 96 | return this._details[entry] 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/reporters/StdReporter.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | module.exports = class StdReporter { 5 | 6 | constructor (entries) { 7 | this._entries = entries 8 | this._details = {} 9 | for (let key in entries) { 10 | this._details[entries[key].host] = [] 11 | } 12 | } 13 | 14 | /** 15 | * Info log, will be printed and added to details 16 | * 17 | * @param {string} entry the entry 18 | * @param {string} msg the message that will be displayed 19 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 20 | */ 21 | info (entry, msg, cb) { 22 | if (!this._entries[entry.host]) { return console.log(entry) } 23 | console.log('[INFO] (%s) %s - %s', entry.name, entry.host, msg) 24 | this._details[entry.host].push(msg) 25 | 26 | return cb ? cb(null, msg) : true 27 | } 28 | 29 | /** 30 | * Error log, will be printed and added to details 31 | * 32 | * @param {string} entry the entry 33 | * @param {string} msg the error instance that has caused the problem 34 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 35 | */ 36 | error (entry, err, cb) { 37 | if (!this._entries[entry.host]) { 38 | return 39 | } 40 | console.log('[ERROR] (%s) %s - %s', entry.name, entry.host, err.message || err) 41 | this._details[entry.host].push(err.message || err) 42 | 43 | return cb ? cb(err) : true 44 | } 45 | 46 | /** 47 | * Success log, will be printed and added to details 48 | * 49 | * @param {string} entry the entry 50 | * @param {string} msg the message that will be displayed 51 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 52 | */ 53 | success (entry, msg, cb) { 54 | if (!this._entries[entry.host]) { 55 | return 56 | } 57 | console.log('[SUCCESS] (%s) %s - %s', entry.name, entry.host, msg) 58 | this._details[entry.host].push(msg) 59 | 60 | return cb ? cb(null, msg) : true 61 | } 62 | 63 | /** 64 | * Fine log, will not be printed but will be added in details 65 | */ 66 | fine (entry, msg, cb) { 67 | if (!this._entries[entry.host]) { 68 | return 69 | } 70 | this._details[entry.host].push(msg) 71 | 72 | return cb ? cb(null, msg) : true 73 | } 74 | 75 | /** 76 | * Will return detailed data for entry provided 77 | * 78 | * @param {string} entry the entry 79 | */ 80 | details (entry) { 81 | return this._details[entry.host] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/reporters/VoidReporter.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | module.exports = class VoidReporter { 5 | 6 | constructor (entries) { 7 | this._entries = entries 8 | 9 | this._details = {} 10 | for (let entry in entries) { 11 | this._details[entry] = [] 12 | } 13 | } 14 | 15 | /** 16 | * Info log, will be printed and added to details 17 | * 18 | * @param {string} entry the entry name 19 | * @param {string} msg the message that will be displayed 20 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 21 | */ 22 | info (entry, msg, cb) { 23 | if (!this._entries[entry]) { return } 24 | this._details[entry].push(msg) 25 | 26 | return cb ? cb(null, msg) : true 27 | } 28 | 29 | /** 30 | * Error log, will be printed and added to details 31 | * 32 | * @param {string} entry the entry name 33 | * @param {string} msg the error instance that has caused the problem 34 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 35 | */ 36 | error (entry, err, cb) { 37 | if (!this._entries[entry]) { 38 | return 39 | } 40 | this._details[entry].push(err.message || err) 41 | 42 | return cb ? cb(err) : true 43 | } 44 | 45 | /** 46 | * Success log, will be printed and added to details 47 | * 48 | * @param {string} entry the entry name 49 | * @param {string} msg the message that will be displayed 50 | * @param {function} cb(err, msg) callback that will be called after (usefull for chaining) 51 | */ 52 | success (entry, msg, cb) { 53 | if (!this._entries[entry]) { 54 | return 55 | } 56 | this._details[entry].push(msg) 57 | 58 | return cb ? cb(null, msg) : true 59 | } 60 | 61 | /** 62 | * Fine log, will not be printed but will be added in details 63 | */ 64 | fine (entry, msg, cb) { 65 | if (!this._entries[entry]) { 66 | return 67 | } 68 | this._details[entry].push(msg) 69 | 70 | return cb ? cb(null, msg) : true 71 | } 72 | 73 | /** 74 | * Will return detailed data for entry provided 75 | * 76 | * @param {string} entry the entry name 77 | */ 78 | details (entry) { 79 | return this._details[entry] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/strategies/GitStrategy.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | const util = require('util') 5 | 6 | module.exports = class GitStrategy { 7 | 8 | constructor (server) { 9 | this.reporter = server.reporter 10 | this.server = server 11 | this.conn = server.conn 12 | 13 | let ref = this.server.ref.split('/') 14 | this.server.origin = this.server.origin || ref[0] || 'origin' 15 | this.server.branch = this.server.branch || ref[1] || 'master' 16 | } 17 | 18 | retrieve (cb) { 19 | // try git clone the repo into the remote source folder 20 | this.server.reporter.info(this.server, 'Retrieving repo in source folder (GIT)') 21 | 22 | let command = util.format('git clone %s -o %s -b %s %s', 23 | this.server.repo, this.server.origin, this.server.branch, this.server.path + '/current') 24 | let stderr = '' 25 | let stdout = '' 26 | 27 | this.conn.shell((err, stream) => { 28 | if (err) return cb(err) 29 | 30 | stream.on('close', (code, signal) => { 31 | this.server.reporter.fine(this.server, { code: code, stdout: stdout || 'empty', stderr: stderr || 'empty' }) 32 | return code !== 0 ? cb(new Error(util.format('Git pull failed with code %d', code))) : cb(null) 33 | }).on('data', (data) => { 34 | console.log(data.toString()) 35 | stdout += data instanceof Buffer ? data.toString() : data 36 | }).stderr.on('data', (data) => { 37 | stderr += data instanceof Buffer ? data.toString() : data 38 | }) 39 | stream.end(command + '\nexit\n') 40 | }) 41 | } 42 | 43 | update (cb) { 44 | // try git reset to head in the repo 45 | this.server.reporter.info(this.server, 'Updating repository from remote (GIT)') 46 | 47 | let command = util.format('cd %s ; git reset --hard %s', this.server.path + '/current', this.server.ref) 48 | let stderr = '' 49 | let stdout = '' 50 | 51 | this.conn.shell((err, stream) => { 52 | if (err) return cb(err) 53 | 54 | stream.on('close', (code, signal) => { 55 | this.server.reporter.fine(this.server, { code: code, stdout: stdout || 'empty', stderr: stderr || 'empty' }) 56 | return code !== 0 ? cb(new Error(util.format('Git reset to HEAD failed with code %d', code))) : cb(null) 57 | }).on('data', (data) => { 58 | stdout += data instanceof Buffer ? data.toString() : data 59 | }).stderr.on('data', (data) => { 60 | stderr += data instanceof Buffer ? data.toString() : data 61 | }) 62 | stream.end(command + '\nexit\n') 63 | }) 64 | } 65 | 66 | rollback (nbr, cb) { 67 | // try git reset to head in the repo 68 | this.server.reporter.info(this.server, 'Updating repository from remote (GIT)') 69 | 70 | let command = util.format('cd %s ; git reset --hard HEAD~%d', this.server.path + '/current', nbr) 71 | let stderr = '' 72 | let stdout = '' 73 | 74 | this.conn.shell((err, stream) => { 75 | if (err) return cb(err) 76 | 77 | stream.on('close', (code, signal) => { 78 | this.server.reporter.fine(this.server, { code: code, stdout: stdout || 'empty', stderr: stderr || 'empty' }) 79 | return code !== 0 ? cb(new Error(util.format('Git reset to HEAD~%d failed with code %d', nbr, code))) : cb(null) 80 | }).on('data', (data) => { 81 | stdout += data instanceof Buffer ? data.toString() : data 82 | }).stderr.on('data', (data) => { 83 | stderr += data instanceof Buffer ? data.toString() : data 84 | }) 85 | stream.end(command + '\nexit\n') 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/utils/ConnUtils.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | const fs = require('fs') 5 | const SSH = require('ssh2').Client 6 | const os = require('os') 7 | const util = require('util') 8 | const VOID = () => {} 9 | 10 | module.exports = class ConnUtils { 11 | 12 | static create (host, server, cb) { 13 | let connection = new SSH() 14 | 15 | connection.once('ready', function () { 16 | server.reporter.fine(server, 'SSH connection is ready') 17 | return cb(null, connection) 18 | }) 19 | connection.once('error', cb) 20 | 21 | // normalize configuration 22 | server.username = server.user || server.username 23 | 24 | // remove end slash if present 25 | if (server.path[server.path.length - 1] === '/') { 26 | server.path = server.path.substr(0, server.path.length - 1) 27 | } 28 | 29 | // set port if contained in hostname 30 | if (server.host.indexOf(':') !== -1) { 31 | let tmp = server.host.split(':') 32 | server.host = tmp[0] 33 | server.port = tmp[1] 34 | } 35 | 36 | // resolve path of private key if given or if there isn't any password configured 37 | if (server.key || server.privateKey || !server.password) { 38 | server.reporter.fine(server, 'Resolving SSH key') 39 | try { 40 | let path = server.key || server.privateKey || '~/.ssh/id_rsa' 41 | path = path.indexOf('~') !== -1 ? path.replace(/~/g, os.homedir()) : path 42 | server.privateKey = fs.readFileSync(path) 43 | server.reporter.fine('SSH key has been succesfully fetched from fs') 44 | } catch (err) { 45 | return cb(err) 46 | } 47 | } 48 | 49 | // build env if provided in configuration 50 | if (server.env) { 51 | let builder = '' 52 | for (let key in server.env) { 53 | builder = util.format('%s%s=%s ', builder, key, server.env[key]) 54 | } 55 | server.env = builder 56 | } else { 57 | server.env = '' 58 | } 59 | 60 | try { 61 | server.reporter.fine(server, 'Initialization of the ssh connection') 62 | if (typeof (server.ssh_options) !== 'object' || server.ssh_options instanceof Array) { 63 | server.ssh_options = {} 64 | } 65 | connection.connect(Object.assign(server, server.ssh_options)) 66 | } catch (err) { 67 | // the error is encoding too long mean that the passphrase is incorrect 68 | return cb(new Error('Bad passphrase provided for ssh private key')) 69 | } 70 | } 71 | 72 | static logRemote (action, server, err) { 73 | server.reporter.info(server, 'Logging action on remote host') 74 | 75 | let command = util.format('cd %s ; echo "[%s] %s run and returned %s" >> .deploys', 76 | server.path, new Date().toISOString(), action, err ? err.message || err : 'success') 77 | 78 | server.conn.shell((err, stream) => { 79 | if (err) return 80 | 81 | stream.on('close', (code, signal) => { 82 | // we don't care if it fail 83 | return 84 | }).on('data', VOID).stderr.on('data', VOID) 85 | stream.end(command + '\nexit\n') 86 | }) 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /lib/utils/DeployUtils.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | const async = require('async') 5 | const readline = require('readline') 6 | const HookUtils = require('./HookUtils') 7 | 8 | const FileState = { 9 | VALID: 0, 10 | INVALID: -1, 11 | NOT_FOUND: -2 12 | } 13 | 14 | module.exports = class DeployUtils { 15 | 16 | /** 17 | * Function used to verify that the remote path is correctly setup (good folder etc) 18 | * 19 | */ 20 | static ensureSetup (server, options, cb) { 21 | if (!cb) { 22 | cb = options 23 | options = {} 24 | } 25 | server.conn.sftp((err, sftp) => { 26 | if (err) return cb(err) 27 | 28 | sftp.readdir(server.path, (err, list) => { 29 | if (err) return cb(err) 30 | 31 | // retrieve both entry based on the name 32 | let current, source 33 | for (let entry of list) { 34 | if (entry.filename === 'current') { current = entry } else if (entry.filename === 'source') { 35 | source = entry 36 | } 37 | } 38 | 39 | let opts = { 40 | force: options ? options.force : false, 41 | current: current ? (current.longname[0] === 'l' ? FileState.VALID : FileState.INVALID) : FileState.NOT_FOUND, 42 | source: source ? (source.longname[0] === 'd' ? FileState.VALID : FileState.INVALID) : FileState.NOT_FOUND 43 | } 44 | 45 | // if any of both folder is invalid or inexistant, setup the remote system 46 | if ((opts.current !== FileState.VALID || opts.source !== FileState.VALID) || options.force) { 47 | return DeployUtils.setup(server, opts, cb) 48 | } else { 49 | return cb(null) 50 | } 51 | }) 52 | }) 53 | } 54 | 55 | /** 56 | * Function used to setup remote system 57 | * 58 | */ 59 | static setup (server, opts, cb) { 60 | async.series([ 61 | function (next) { 62 | if (opts.force !== true) return next(null) 63 | 64 | server.conn.sftp((err, sftp) => { 65 | if (err) return next(err) 66 | 67 | server.reporter.info(server, 'Removing old folders') 68 | sftp.unlink(server.path + '/current_old', (err1) => { 69 | sftp.rmdir(server.path + '/source_old', (err2) => { 70 | return next() 71 | }) 72 | }) 73 | }) 74 | }, 75 | // if the remote source folder already exist, move him 76 | // or we are forced to make a backup and the file exist 77 | function (next) { 78 | if (!(opts.source === FileState.INVALID || (opts.force && opts.source === FileState.VALID))) return next(null) 79 | 80 | server.conn.sftp((err, sftp) => { 81 | if (err) return next(err) 82 | 83 | server.reporter.info(server, 'Making a backup of remote source folder') 84 | sftp.rename(server.path + '/source', server.path + '/source_old', next) 85 | }) 86 | }, 87 | // if the remote current link isn't a link, move him too 88 | // or we are forced to move it and the file exist 89 | function (next) { 90 | if (opts.current !== FileState.INVALID && 91 | !(opts.force && opts.source === FileState.VALID)) return next(null) 92 | 93 | server.conn.sftp((err, sftp) => { 94 | if (err) return next(err) 95 | 96 | server.reporter.info(server, 'Making a backup of remote current symlink') 97 | sftp.rename(server.path + '/current', server.path + '/current_old', next) 98 | }) 99 | }, 100 | // at this point we either valid or inexistant folder, so create it if needed 101 | function (next) { 102 | if (opts.source === FileState.VALID && !opts.force) return next(null) 103 | 104 | server.conn.sftp((err, sftp) => { 105 | if (err) return next(err) 106 | 107 | server.reporter.info(server, 'Creating source folder') 108 | sftp.mkdir(server.path + '/source', next) 109 | }) 110 | }, 111 | // create symlink if needed 112 | function (next) { 113 | if (opts.current === FileState.VALID && !opts.force) return next(null) 114 | 115 | server.conn.sftp((err, sftp) => { 116 | if (err) return next(err) 117 | 118 | server.reporter.info(server, 'Create current symlink to current') 119 | sftp.symlink(server.path + '/source', server.path + '/current', next) 120 | }) 121 | }, 122 | // now we have a valid remote setup, so if the source folder wasn't already here, call hooks 123 | function (next) { 124 | if (opts.source === FileState.VALID && !opts.force) return next(null) 125 | 126 | // execute pre-hook 127 | HookUtils.call('pre-setup', server, (err) => { 128 | if (err) return next(err) 129 | server.strategy.retrieve((err) => { 130 | if (err) return next(err) 131 | HookUtils.call('post-setup', server, next) 132 | }) 133 | }) 134 | } 135 | ], cb) 136 | } 137 | 138 | static resolveServers (envs, groups, name) { 139 | var hosts = {} 140 | let env = envs[name] 141 | 142 | if (env && env.host) { 143 | if (typeof (env.host) === 'string') { 144 | hosts[env.host] = env 145 | } else if (env.host instanceof Array) { 146 | env.host.forEach((host) => { 147 | hosts[host] = env 148 | }) 149 | } 150 | return hosts 151 | } else if (groups[name]) { 152 | for (let env of groups[name]) { 153 | if (typeof (env.host) === 'string') { 154 | hosts[env.host] = env 155 | } else if (env.host instanceof Array) { 156 | env.host.forEach((host) => { 157 | hosts[host] = env 158 | }) 159 | } 160 | } 161 | return hosts 162 | } else { 163 | return new Error('Environement/Group provided not found') 164 | } 165 | } 166 | 167 | static askUser (query, hide, cb) { 168 | if (!cb) { 169 | cb = hide 170 | hide = undefined 171 | } 172 | 173 | var rl = readline.createInterface({ 174 | input: process.stdin, 175 | output: process.stdout 176 | }) 177 | 178 | if (hide === true) { 179 | process.stdin.on('data', function (char) { 180 | char = char + '' 181 | switch (char) { 182 | case '\n': 183 | case '\r': 184 | case '\u0004': 185 | process.stdin.pause() 186 | break 187 | default: 188 | process.stdout.write('\x1B[2K\x1B[200D' + query + Array(rl.line.length + 1).join('*')) 189 | break 190 | } 191 | }) 192 | } 193 | 194 | rl.question(query, (value) => { 195 | rl.close() 196 | return cb(value) 197 | }) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /lib/utils/HookUtils.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | 4 | const util = require('util') 5 | const async = require('async') 6 | const exec = require('child_process').exec 7 | 8 | module.exports = class HookUtils { 9 | 10 | /** 11 | * Function used to call a configured hook (if exist) on the remote system 12 | */ 13 | static callRemote (hook, server, cb) { 14 | if (!server[hook]) { return cb(null) } 15 | if (typeof (server[hook]) !== 'string') { 16 | return cb(new Error(`Remote '${hook}' hook configured is not a string`)) 17 | } 18 | 19 | server.reporter.info(server, `Executing ${hook} on remote system`) 20 | let stderr = '' 21 | let stdout = '' 22 | 23 | server.conn.shell((err, stream) => { 24 | if (err) return cb(err) 25 | 26 | stream.on('close', (code, signal) => { 27 | server.reporter.fine(server, { hook: hook, code: code, stdout: stdout || 'empty', stderr: stderr || 'empty' }) 28 | return server.reporter.info(server, `${hook} hook returned code ${code}`, cb) 29 | }).on('data', (data) => { 30 | stdout += data instanceof Buffer ? data.toString() : data 31 | }).stderr.on('data', (data) => { 32 | stderr += data instanceof Buffer ? data.toString() : data 33 | }) 34 | 35 | stream.end(util.format('cd %s ; %s %s \nexit\n', server.path + '/current', server.env, server[hook])) 36 | }) 37 | } 38 | 39 | /** 40 | * Function used to call a configured hook (if exist) on the local system 41 | */ 42 | static callLocal (hook, server, cb) { 43 | if (!server[hook]) { 44 | return cb(null) 45 | } 46 | if (typeof (server[hook]) !== 'string') { 47 | return cb(new Error(`Local '${hook}' hook configured is not a string`)) 48 | } 49 | 50 | server.reporter.info(server, `Executing ${hook} on local system`) 51 | 52 | exec(server[hook], (err, stdout, stderr) => { 53 | if (err) return cb(err) 54 | 55 | server.reporter.fine(server, { hook: hook, code: 0, stdout: stdout || '', stderr: stderr || '' }) 56 | return server.reporter.info(server, `${hook} hook returned code 0`, cb) 57 | }) 58 | } 59 | 60 | /** 61 | * Function used to call both hook (remote and local) asynchronously 62 | */ 63 | static call (hook, server, cb) { 64 | async.parallel([ 65 | function (next) { 66 | HookUtils.callLocal(hook + '-local', server, next) 67 | }, 68 | function (next) { 69 | HookUtils.callRemote(hook, server, next) 70 | } 71 | ], cb) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/utils/config.js: -------------------------------------------------------------------------------- 1 | 2 | // declared internal reporters and strategies 3 | module.exports = { 4 | reporters: { 5 | STD: require('../reporters/StdReporter'), 6 | SPINNER: require('../reporters/SpinnerReporter'), 7 | VOID: require('../reporters/VoidReporter') 8 | }, 9 | strategies: { 10 | GIT: require('../strategies/GitStrategy') 11 | }, 12 | rollback: 1 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deployerjs", 3 | "version": "0.2.2", 4 | "description": "", 5 | "main": "index.js", 6 | "directories": { 7 | "bin": "./bin", 8 | "lib": "./lib", 9 | "example": "./examples" 10 | }, 11 | "scripts": { 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "bin": { 15 | "deployer": "./bin/deployer" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/vmarchaud/deployerjs.git" 20 | }, 21 | "author": "vmarchaud", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/vmarchaud/deployerjs/issues" 25 | }, 26 | "homepage": "https://github.com/vmarchaud/deployerjs#readme", 27 | "dependencies": { 28 | "async": "^2.1.2", 29 | "commander": "^2.9.0", 30 | "multispinner": "github:vmarchaud/node-multispinner", 31 | "ssh2": "^0.5.2" 32 | }, 33 | "devDependencies": { 34 | "chai": "^3.5.0", 35 | "mocha": "^3.2.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/ssh_server.js: -------------------------------------------------------------------------------- 1 | 2 | 'use scrict' 3 | 4 | const crypto = require('crypto') 5 | const ssh2 = require('ssh2') 6 | const utils = ssh2.utils 7 | const EventEmitter = require('events') 8 | 9 | class EventBus extends EventEmitter {} 10 | 11 | export class ServerWrapper { 12 | 13 | static buildServer (opts) { 14 | if (opts.privateKey && !opts.publicKey) { 15 | opts.publicKey = utils.genPublicKey(opts.privateKey) 16 | } 17 | const eventBus = new EventBus() 18 | const server = new ssh2.Server({ 19 | hostKeys: [ opts.privateKey ] 20 | }, function (client) { 21 | client.on('authentication', (ctx) => { 22 | if (this.methods.indexOf(ctx.method) < 0) { 23 | return ctx.reject() 24 | } 25 | // auth using password 26 | if (ctx.method === 'password' && 27 | ctx.username === opts.userrname && 28 | ctx.password === opts.password) { 29 | return ctx.accept() 30 | } 31 | // auth using public key 32 | if (ctx.method === 'publickey' && ctx.key.algo === opts.publicKey.fulltype && 33 | ctx.key.data.equals(opts.publicKey.public)) { 34 | if (!ctx.signature) return ctx.accept() 35 | 36 | var verifier = crypto.createVerify(ctx.sigAlgo) 37 | verifier.update(ctx.blob) 38 | return verifier.verify(opts.pubKey.publicOrig, ctx.signature) 39 | ? ctx.accept() : ctx.reject() 40 | } 41 | }).on('ready', () => { 42 | client.on('session', (accept, reject) => { 43 | eventBus.emit('session', accept()) 44 | }) 45 | }).on('end', () => { 46 | client.emit('disconnect') 47 | }) 48 | }).listen(opts.port, opts.host, () => {}) 49 | return { bus: eventBus, server: server } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/integrations/setup.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmarchaud/deployerjs/91a9f46b1c5c312b9c9eb9f4434f0969f5d149b6/test/integrations/setup.js -------------------------------------------------------------------------------- /test/units/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict' 3 | 4 | const chai = require('chai') 5 | const expect = chai.expect 6 | const Deployer = require('../../lib/Deployer') 7 | const config = require('../../lib/utils/config') 8 | 9 | describe('Configuration', () => { 10 | describe('Deployer constructor', () => { 11 | const sample = { 12 | test: { 13 | group: 'sample', 14 | username: 'notroot', 15 | password: 'notadmin', 16 | host: '127.0.0.1' 17 | }, 18 | production: { 19 | group: 'sample', 20 | username: 'notroot', 21 | password: 'notadmin', 22 | host: '127.0.0.1' 23 | } 24 | } 25 | 26 | it('should create a Deployer instance', () => { 27 | let deployer = new Deployer(sample) 28 | expect(deployer).to.be.an.instanceof(Deployer) 29 | }) 30 | 31 | it('should throw an error when no args', () => { 32 | let fn = function () { 33 | let deployer = new Deployer() 34 | deployer.lol() 35 | } 36 | expect(fn).to.throw(/Invalid/) 37 | }) 38 | 39 | it('should have put both envs in same group', () => { 40 | let deployer = new Deployer(sample) 41 | expect(deployer.groups).to.exist 42 | expect(deployer.groups).to.have.all.keys(['sample']) 43 | expect(deployer.groups.sample).to.have.deep.property('[0].name', 'test') 44 | expect(deployer.groups.sample).to.have.deep.property('[1].name', 'production') 45 | }) 46 | 47 | it('should set default options', () => { 48 | let deployer = new Deployer(sample) 49 | expect(deployer.strategy).to.equal(config.strategies.GIT) 50 | expect(deployer.reporter).to.equal(config.reporters.STD) 51 | }) 52 | 53 | it('should use options when provided as string', () => { 54 | let deployer = new Deployer(sample, { 55 | reporter: 'spinner', 56 | strategy: 'git' 57 | }) 58 | expect(deployer.strategy).to.equal(config.strategies.GIT) 59 | expect(deployer.reporter).to.equal(config.reporters.SPINNER) 60 | }) 61 | 62 | it('should fallback to default option when invalid provided', () => { 63 | let deployer = new Deployer(sample, { 64 | reporter: 'toto', 65 | strategy: 'tata' 66 | }) 67 | expect(deployer.strategy).to.equal(config.strategies.GIT) 68 | expect(deployer.reporter).to.equal(config.reporters.STD) 69 | }) 70 | 71 | it('should use options when provided as function', () => { 72 | let deployer = new Deployer(sample, { 73 | reporter: config.reporters.VOID, 74 | strategy: config.strategies.GIT 75 | }) 76 | expect(deployer.strategy).to.equal(config.strategies.GIT) 77 | expect(deployer.reporter).to.equal(config.reporters.VOID) 78 | }) 79 | }) 80 | 81 | describe('Selecting environnements', () => { 82 | let sample = { 83 | staging1: { 84 | group: 'dev', 85 | username: 'notroot', 86 | password: 'notadmin', 87 | host: '127.0.0.1' 88 | }, 89 | prod1: { 90 | group: 'prod', 91 | username: 'notroot', 92 | password: true, 93 | host: '127.0.0.1' 94 | }, 95 | staging2: { 96 | group: 'dev', 97 | username: 'notroot', 98 | password: 'notadmin', 99 | host: 'localhost' 100 | } 101 | } 102 | 103 | it('should select a group w/ one host per env', (done) => { 104 | let deployer = new Deployer(sample, { reporter: config.reporters.VOID }) 105 | deployer.select('dev', (err, servers) => { 106 | expect(err).to.be.null 107 | expect(Object.keys(servers).length).to.be.equal(2) 108 | expect(servers).to.have.all.keys([sample.staging1.host, sample.staging2.host]) 109 | return done() 110 | }) 111 | }) 112 | 113 | it('should select a group w/ multiple host per env', (done) => { 114 | var conf = JSON.parse(JSON.stringify(sample)) 115 | conf.staging2.host = ['localhost:3030', 'localhost:4000'] 116 | conf.staging1.host = ['127.0.0.1:3984', '127.0.0.1:3000'] 117 | let deployer = new Deployer(conf, { reporter: config.reporters.VOID }) 118 | deployer.select('dev', (err, servers) => { 119 | expect(err).to.be.null 120 | expect(Object.keys(servers).length).to.be.equal(4) 121 | expect(servers[conf.staging2.host[0]].name).to.equal('staging2') 122 | expect(servers[conf.staging2.host[1]].name).to.equal('staging2') 123 | expect(servers[conf.staging1.host[0]].name).to.equal('staging1') 124 | expect(servers[conf.staging1.host[1]].name).to.equal('staging1') 125 | return done() 126 | }) 127 | }) 128 | 129 | it.skip('should select env and ask for password', (done) => { 130 | var conf = JSON.parse(JSON.stringify(sample)) 131 | let deployer = new Deployer(conf, { reporter: config.reporters.VOID }) 132 | deployer.select('prod1', (err, servers) => { 133 | expect(err).to.be.null 134 | // TODO 135 | return done() 136 | }) 137 | setTimeout(() => { 138 | process.stdin.write('toto\n') 139 | process.stdin.flush 140 | }, 1000) 141 | }) 142 | }) 143 | }) 144 | --------------------------------------------------------------------------------